Compare commits

..

54 Commits

Author SHA1 Message Date
spaced4ndy e67feeea22 operators: debug decoding 2024-11-12 11:02:58 +04:00
Evgeny Poberezkin da65474452 CLI API in progress, validateUserServers 2024-11-11 23:24:11 +00:00
Evgeny Poberezkin d0a7e14a96 make preset servers lists potentially empty in some operators, as long as the combined list is not empty 2024-11-11 16:22:19 +00:00
Evgeny Poberezkin bd4745775d update 2024-11-11 11:34:02 +00:00
Evgeny Poberezkin af144c6208 fix 2024-11-10 22:58:23 +00:00
Evgeny Poberezkin 74206a947b Merge branch 'server-operators' into ep/operators-preset-servers 2024-11-10 16:15:39 +00:00
Evgeny Poberezkin 457e12880c Merge branch 'master' into server-operators 2024-11-10 16:15:07 +00:00
Evgeny 2d588949b1 directory service: additional commands (#5159)
* directory service: additional commands

* notify superusers

* 48 hours

* replace T.elem
2024-11-10 15:21:33 +00:00
Evgeny Poberezkin 84f7f901ea remove imports 2024-11-10 12:57:57 +00:00
Evgeny Poberezkin 90ed503ee0 update (most tests pass) 2024-11-10 12:30:24 +00:00
Evgeny Poberezkin 28105038d4 preset operators and servers (compiles) 2024-11-09 23:53:18 +00:00
Evgeny Poberezkin fd60a2402a Merge branch 'server-operators' into ep/operators-preset-servers 2024-11-08 14:36:47 +00:00
spaced4ndy ef0f21a11c core: operator apis commands (#5155) 2024-11-08 14:45:00 +04:00
spaced4ndy d0e40c1f1d Merge branch 'server-operators' into ep/operators-preset-servers 2024-11-06 16:20:03 +04:00
spaced4ndy 8396e70e7b core: validate servers - find servers with duplicate hosts (#5150) 2024-11-06 16:13:08 +04:00
Evgeny Poberezkin 6128a24869 database entity IDs 2024-11-06 08:12:16 +00:00
Evgeny Poberezkin 0329a6a7d3 Merge branch 'server-operators' into ep/operators-preset-servers 2024-11-05 18:07:09 +00:00
spaced4ndy 2da89c2cf1 core: setConditionsNotified, acceptConditions, setUserServers, validateServers apis wip (#5147) 2024-11-05 21:40:33 +04:00
Evgeny Poberezkin 601ddf97ce WIP 2024-11-05 16:29:30 +00:00
Evgeny Poberezkin 128d031ced Merge branch 'server-operators' into ep/operators-preset-servers 2024-11-05 10:55:36 +00:00
spaced4ndy 3b0205b25f core: setServerOperators, getUsageConditions api wip (#5145) 2024-11-05 14:15:20 +04:00
Evgeny Poberezkin d4a47f1cce simplify 2024-11-04 20:07:28 +00:00
Evgeny Poberezkin 2998e3af3f usageConditionsToAdd 2024-11-04 19:44:45 +00:00
Evgeny Poberezkin 5ff838d63e Merge branch 'server-operators' into ep/operators-preset-servers 2024-11-04 18:19:24 +00:00
spaced4ndy bdaec30fa0 core: getServerOperators, getUserServers, getUsageConditions apis wip (#5141) 2024-11-04 21:11:03 +04:00
Evgeny Poberezkin 5a8bf9106e core: preset servers and operators (WIP) 2024-11-04 16:42:37 +00:00
Evgeny 97df069730 core: add support for server operators (#4961)
* core: add support for server operators

* migration

* update schema and queries, rfc

* add usage conditions tables

* core: server operators new apis draft

* update

* conditions

* update

* add get conditions api

* add get conditions API

* WIP

* compiles

* fix schema

* core: ui logic in types (#5139)

* update

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-11-04 13:28:57 +00:00
Evgeny Poberezkin 9a1166f097 Merge branch 'master' into server-operators 2024-11-03 09:12:12 +00:00
Evgeny Poberezkin 7a741e7ac4 ios: update core library 2024-11-02 20:03:27 +00:00
Alexander Bondarenko 165143a111 Use simplexmq with client_library flag (#5133)
* Use simplexmq with client_library flag

* fix server config for mq master

* simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-11-02 17:51:11 +00:00
Evgeny ceb17b23b4 bumped haskell.nix (#5134)
Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2024-11-02 15:28:41 +00:00
Evgeny Poberezkin 3c8c9d8b52 website: update jobs page 2024-11-02 13:43:45 +00:00
Stanislav Dmitrenko 4162bccc46 multiplatform: edge to edge design (#5051)
* multiplatform: insets

* more features and better performance

* calls and removed unused code

* changes

* removed logs

* status and nav bar colors

* chatList and newChatSheet search fields

* overhaul

* search fields, devtools, chatlist, newchatsheet, onehand on desktop, scrollbars

* android, desktop: update to Compose 1.7.0

- support image drag-and-drop from other applications right to a chat
(with and without transparent pixels - will be png or jpg)

* stable

* workaround

* changes

* ideal adapting height layout

* dropdownmenu, userpicker, onehandui, call layout, columns

* rename bars properties and strings

* faster update and better layout

* gallery in landscape with cutout

* better cutout

* 1% step on slider

* app bar moves to bottom in one hand ui

* default alpha

* changes

* userpicker colors

* changes

* blur

* fix wrong drawing area in chatview

* fix

* fixed differently

* changes

* changes

* android fix

* Revert "android fix"

This reverts commit 7d417afd9b.

* changes

* changes

* blur

* swap

* no logs

* fix build

* old Android support

* fix position of menu

* disable blur on Android 12

* call button padding

* useless code

* fix padding in group info view

* rename

* rename

* newline

* one more fix

* changes

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-10-31 17:26:17 +00:00
Stanislav Dmitrenko 24090fe350 android, desktop: update to Compose 1.7.0 (#5038)
* docs: correction

* android, desktop: update to Compose 1.7.0

- support image drag-and-drop from other applications right to a chat
(with and without transparent pixels - will be png or jpg)

* stable

* workaround

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-10-31 17:11:26 +00:00
spaced4ndy 37b78edb91 ios: move Network and servers settings modules to folder (#5110) 2024-10-28 18:18:26 +04:00
spaced4ndy 78510b6fd3 core, ios: get messages for multiple last notifications; separately get notification connections before requesting messages (to avoid acknowledgement races in case of parralel nse threads); coordinate nse threads (#5084)
* core, ios: get messages for multiple last notifications (#5047)

* ios: refactor notification service (#5086)

* core, ios: separately get notification connections before requesting messages; coordinate nse threads (#5085)
2024-10-25 20:09:59 +04:00
Evgeny Poberezkin edf99fcd1d 6.1.1: ios 245, android 249, desktop 74 2024-10-18 18:37:14 +01:00
spaced4ndy 2ffabd1ef8 ios: fix changing user via notification (#5069) 2024-10-18 18:07:38 +04:00
Evgeny Poberezkin 28383edb83 core: 6.1.1.0 (simplexmq: 6.1.1.0) 2024-10-18 14:21:17 +01:00
Evgeny 9175897acf core, ui: add SMP STORE error (#5071)
* core, ui: add SMP STORE error

* update library
2024-10-18 14:17:04 +01:00
Evgeny Poberezkin f3cd167502 core: ntf server 2024-10-18 13:06:47 +01:00
Arturs Krumins 7cde2cf6c2 ios: optimise ComposeView rendering (#5042)
* ios: replace revealed bindings with constant value

* ios: optimise ComposeView rendering

* rename

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-10-18 12:35:27 +01:00
Arturs Krumins 3913043705 ios: fix chat not loading if initial page has too many merged items (#5066)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-10-18 10:07:18 +01:00
Arturs Krumins a160acef12 ios: fix navigation title redaction after biometric authentication (#5065) 2024-10-18 10:04:53 +01:00
Arturs Krumins c54fae0136 ios: fix sheets dismissing during biometric authentication (#5062)
* ios: fix sheets dismissing during biometric authentication

* remove AppSheet

* Revert "remove AppSheet"

This reverts commit 3aa1688cbd.

* remove local auth request on sheet dismissal

* revert biometricAuth
2024-10-16 19:55:59 +01:00
Arturs Krumins d57abfcc93 ios: fix theme import file picker (#5048)
* ios: fix theme import file picker

* minor
2024-10-16 19:48:13 +01:00
Evgeny 515a0ddfdd blog: wired's attack on privacy (#5063)
* blog: wired misleading attack on privacy of communications

* image

* update

* title

* update

* update

* preview
2024-10-16 19:25:47 +01:00
spaced4ndy b5d8c65249 ui: quota error description (#5037) 2024-10-15 12:01:06 +04:00
Arturs Krumins de94892fe7 ios: replace revealed bindings with constant value (#5027) 2024-10-15 08:58:54 +01:00
Evgeny Poberezkin b7131e16f2 docs: fix links 2024-10-14 13:27:04 +01:00
Evgeny 11a44dc1fd blog: v6.1 and security review announcement (#5040)
* blog: v6.1 and security review announcement

* update, images

* readme

* update review links on home page

* links to review
2024-10-14 13:18:48 +01:00
sh 0af718f03f flatpak: update metainfo (#5039) 2024-10-14 10:12:00 +01:00
Evgeny Poberezkin f8f5c3c6be docs: correction 2024-10-14 09:37:31 +01:00
sh 13912a4af9 docs/smp-server: update to latest changes (#4960)
* docs/smp-server: update to latest changes

* update

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-10-13 09:43:47 +01:00
225 changed files with 6180 additions and 3055 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat
RUN cabal update
RUN cabal build exe:simplex-chat
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library'
# Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \
+4 -2
View File
@@ -25,7 +25,9 @@ While SimpleX Chat Ltd is not a communication service provider, and provide publ
We see users and data sovereignty, and device and provider portability as critically important properties for any communication system.
SimpleX Chat security assessment was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2 see [the announcement](/blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The cryptographic review of SimpleX protocols design was done in July 2024 by Trail of Bits see [the announcement](/blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
### Your information
@@ -172,4 +174,4 @@ You accept the Conditions of Use of Software and Infrastructure ("Conditions") b
**Ending these conditions**. You may end these Conditions with SimpleX Chat Ltd at any time by deleting our Applications from your devices and discontinuing use of our Infrastructure. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the conditions, Enforcing the conditions, and Ending these conditions will survive termination of your relationship with SimpleX Chat Ltd.
Updated April 24, 2024
Updated October 14, 2024
+5 -9
View File
@@ -233,14 +233,12 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates:
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
[Aug 14, 2024. SimpleX network: the investment from Jack Dorsey and Asymmetric, v6.0 released with the new user experience and private message routing](./blog/20240814-simplex-chat-vision-funding-v6-private-routing-new-user-experience.md)
[Jun 4, 2024. SimpleX network: private message routing, v5.8 released with IP address protection and chat themes](./blog/20240604-simplex-chat-v5.8-private-message-routing-chat-themes.md)
[Apr 26, 2024. SimpleX network: legally binding transparency, v5.7 released with better calls and messages.](./blog/20240426-simplex-legally-binding-transparency-v5-7-better-user-experience.md)
[Mar 23, 2024. SimpleX network: real privacy and stable profits, non-profits for protocols, v5.6 released with quantum resistant e2e encryption and simple profile migration.](./blog/20240323-simplex-network-privacy-non-profit-v5-6-quantum-resistant-e2e-encryption-simple-migration.md)
[Mar 14, 2024. SimpleX Chat v5.6 beta: adding quantum resistance to Signal double ratchet algorithm.](./blog/20240314-simplex-chat-v5-6-quantum-resistance-signal-double-ratchet-algorithm.md)
[Jan 24, 2024. SimpleX Chat: free infrastructure from Linode, v5.5 released with private notes, group history and a simpler UX to connect.](./blog/20240124-simplex-chat-infrastructure-costs-v5-5-simplex-ux-private-notes-group-history.md)
@@ -249,10 +247,6 @@ Recent and important updates:
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
@@ -409,7 +403,9 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed, and had many breaking changes and improvements in v1.0.0.
The security audit was performed in October 2022 by [Trail of Bits](https://www.trailofbits.com/about), and most fixes were released in v4.2.0 see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The implementation security assessment of SimpleX cryptography and networking was done in October 2022 by [Trail of Bits](https://www.trailofbits.com/about) see [the announcement](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
The cryptographic review of SimpleX protocols was done in July 2024 by Trail of Bits see [the announcement](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md).
SimpleX Chat is still a relatively early stage platform (the mobile apps were released in March 2022), so you may discover some bugs and missing features. We would really appreciate if you let us know anything that needs to be fixed or improved.
+3 -1
View File
@@ -13,6 +13,7 @@ struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var alertManager = AlertManager.shared
@ObservedObject var callController = CallController.shared
@ObservedObject var appSheetState = AppSheetState.shared
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var sceneDelegate: SceneDelegate
@@ -250,7 +251,8 @@ struct ContentView: View {
private func mainView() -> some View {
ZStack(alignment: .top) {
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet).privacySensitive(protectScreen)
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet)
.redacted(reason: appSheetState.redactionReasons(protectScreen))
.onAppear {
requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete
+1
View File
@@ -147,6 +147,7 @@ final class ChatModel: ObservableObject {
@Published var chatDbEncrypted: Bool?
@Published var chatDbStatus: DBMigrationResult?
@Published var ctrlInitInProgress: Bool = false
@Published var notificationResponse: UNNotificationResponse?
// local authentication
@Published var contentViewAccessAuthenticated: Bool = false
@Published var laRequest: LocalAuthRequest?
+29 -10
View File
@@ -29,17 +29,33 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
private var granted = false
private var prevNtfTime: Dictionary<ChatId, Date> = [:]
override init() {
super.init()
UNUserNotificationCenter.current().delegate = self
}
// Handle notification when app is in background
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler handler: () -> Void) {
logger.debug("NtfManager.userNotificationCenter: didReceive")
let content = response.notification.request.content
if appStateGroupDefault.get() == .active {
processNotificationResponse(response)
} else {
logger.debug("NtfManager.userNotificationCenter: remember response in model")
ChatModel.shared.notificationResponse = response
}
handler()
}
func processNotificationResponse(_ ntfResponse: UNNotificationResponse) {
let chatModel = ChatModel.shared
let action = response.actionIdentifier
logger.debug("NtfManager.userNotificationCenter: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
let content = ntfResponse.notification.request.content
let action = ntfResponse.actionIdentifier
logger.debug("NtfManager.processNotificationResponse: didReceive: action \(action), categoryIdentifier \(content.categoryIdentifier)")
if let userId = content.userInfo["userId"] as? Int64,
userId != chatModel.currentUser?.userId {
logger.debug("NtfManager.processNotificationResponse changeActiveUser")
changeActiveUser(userId, viewPwd: nil)
}
if content.categoryIdentifier == ntfCategoryContactRequest && (action == ntfActionAcceptContact || action == ntfActionAcceptContactIncognito),
@@ -61,7 +77,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
ItemsModel.shared.loadOpenChat(chatId)
}
}
handler()
}
private func ntfCallAction(_ content: UNNotificationContent, _ action: String) -> (ChatId, NtfCallAction)? {
@@ -76,7 +91,6 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
return nil
}
// Handle notification when the app is in foreground
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@@ -185,6 +199,12 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("SimpleX encrypted message or connection event", comment: "notification")
),
UNNotificationCategory(
identifier: ntfCategoryManyEvents,
actions: [],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: NSLocalizedString("New events", comment: "notification")
)
])
}
@@ -210,29 +230,28 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
}
}
}
center.delegate = self
}
func notifyContactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest) {
logger.debug("NtfManager.notifyContactRequest")
addNotification(createContactRequestNtf(user, contactRequest))
addNotification(createContactRequestNtf(user, contactRequest, 0))
}
func notifyContactConnected(_ user: any UserLike, _ contact: Contact) {
logger.debug("NtfManager.notifyContactConnected")
addNotification(createContactConnectedNtf(user, contact))
addNotification(createContactConnectedNtf(user, contact, 0))
}
func notifyMessageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) {
logger.debug("NtfManager.notifyMessageReceived")
if cInfo.ntfsEnabled {
addNotification(createMessageReceivedNtf(user, cInfo, cItem))
addNotification(createMessageReceivedNtf(user, cInfo, cItem, 0))
}
}
func notifyCallInvitation(_ invitation: RcvCallInvitation) {
logger.debug("NtfManager.notifyCallInvitation")
addNotification(createCallInvitationNtf(invitation))
addNotification(createCallInvitationNtf(invitation, 0))
}
func setNtfBadgeCount(_ count: Int) {
+6
View File
@@ -743,6 +743,12 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
)
return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))):
let alert = mkAlert(
title: "Undelivered messages",
message: "The connection reached the limit of undelivered messages, your contact may be offline."
)
return (nil, alert)
case let .chatCmdError(_, .errorAgent(.INTERNAL(internalErr))):
if internalErr == "SEUniqueID" {
let alert = mkAlert(
+11 -5
View File
@@ -82,11 +82,17 @@ struct SimpleXApp: App {
if appState != .stopped {
startChatAndActivate {
if appState.inactive && chatModel.chatRunning == true {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
if chatModel.chatRunning == true {
if let ntfResponse = chatModel.notificationResponse {
chatModel.notificationResponse = nil
NtfManager.shared.processNotificationResponse(ntfResponse)
}
if appState.inactive {
Task {
await updateChats()
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations()
}
}
}
}
@@ -11,11 +11,11 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.revealed) var revealed: Bool
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature
var icon: String? = nil
var iconColor: Color
@@ -106,6 +106,9 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chat: Chat.sampleData, chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary))
CIChatFeatureView(
chat: Chat.sampleData,
chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor(.secondary)
).environment(\.revealed, true)
}
}
@@ -285,17 +285,18 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, 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(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: sentFile)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample())
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation))
ChatItemView(chat: Chat.sampleData, 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(chat: Chat.sampleData, chatItem: fileChatItemWtFile)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}
@@ -510,10 +510,10 @@ struct CIVoiceView_Previews: PreviewProvider {
duration: 30,
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -92,12 +92,13 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, 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))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)))
ChatItemView(chat: Chat.sampleData, 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(chat: Chat.sampleData, chatItem: voiceMessageWithQuote)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 360))
}
}
File diff suppressed because one or more lines are too long
@@ -12,9 +12,9 @@ import SimpleXChat
struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
@Binding var revealed: Bool
var body: some View {
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
@@ -79,7 +79,10 @@ struct MarkedDeletedItemView: View {
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
MarkedDeletedItemView(
chat: Chat.sampleData,
chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))
).environment(\.revealed, true)
}
.previewLayout(.fixed(width: 360, height: 200))
}
+31 -28
View File
@@ -14,19 +14,28 @@ extension EnvironmentValues {
static let defaultValue: Bool = true
}
struct Revealed: EnvironmentKey {
static let defaultValue: Bool = true
}
var showTimestamp: Bool {
get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue }
}
var revealed: Bool {
get { self[Revealed.self] }
set { self[Revealed.self] = newValue }
}
}
struct ChatItemView: View {
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.revealed) var revealed: Bool
var chatItem: ChatItem
var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool
@Binding var allowMenu: Bool
init(
@@ -34,27 +43,25 @@ struct ChatItemView: View {
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false)
) {
self.chat = chat
self.chatItem = chatItem
self.maxWidth = maxWidth
_revealed = revealed
_allowMenu = allowMenu
}
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
MarkedDeletedItemView(chat: chat, chatItem: chatItem)
} else if ci.quotedItem == nil && ci.meta.itemForwarded == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
ChatItemContentView(chat: chat, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@@ -84,7 +91,6 @@ struct ChatItemView: View {
chat: chat,
chatItem: chatItem,
preview: preview,
revealed: $revealed,
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
@@ -96,9 +102,9 @@ struct ChatItemView: View {
struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.revealed) var revealed: Bool
@ObservedObject var chat: Chat
var chatItem: ChatItem
@Binding var revealed: Bool
var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@@ -130,7 +136,7 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: theme.colors.secondary)
case let .rcvGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .sndGroupFeature(feature, preference, _, role): chatFeatureView(feature, preference.enabled(role, for: chat.chatInfo.groupInfo?.membership).iconColor(theme.colors.secondary))
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@@ -177,7 +183,7 @@ struct ChatItemContentView<Content: View>: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chat: chat, chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
CIChatFeatureView(chat: chat, chatItem: chatItem, feature: feature, iconColor: iconColor)
}
private var mergedGroupEventText: Text? {
@@ -238,16 +244,17 @@ func chatEventText(_ ci: ChatItem, _ secondaryColor: Color) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true)).environment(\.revealed, true)
}
.environment(\.revealed, false)
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
@@ -265,8 +272,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvIntegrityError(msgError: .msgSkipped(fromMsgId: 1, toMsgId: 2)),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
)
ChatItemView(
chat: Chat.sampleData,
@@ -276,8 +282,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvDecryptionError(msgDecryptError: .ratchetHeader, msgCount: 2),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
)
ChatItemView(
chat: Chat.sampleData,
@@ -287,8 +292,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupInvitation(groupInvitation: CIGroupInvitation.getSample(status: .pending), memberRole: .admin),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
)
ChatItemView(
chat: Chat.sampleData,
@@ -298,8 +302,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)),
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
)
ChatItemView(
chat: Chat.sampleData,
@@ -309,10 +312,10 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
content: ciFeatureContent,
quotedItem: nil,
file: nil
),
revealed: Binding.constant(true)
)
)
}
.environment(\.revealed, true)
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
}
+1 -1
View File
@@ -1183,9 +1183,9 @@ struct ChatView: View {
chat: chat,
chatItem: ci,
maxWidth: maxWidth,
revealed: .constant(revealed),
allowMenu: $allowMenu
)
.environment(\.revealed, revealed)
.environment(\.showTimestamp, itemSeparation.timestamp)
.modifier(ChatItemClipped(ci, tailVisible: itemSeparation.largeGap && (ci.meta.itemDeleted == nil || revealed)))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
@@ -287,8 +287,8 @@ struct ComposeView: View {
// this is a workaround to fire an explicit event in certain cases
@State private var stopPlayback: Bool = false
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
@UserDefault(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@UserDefault(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
VStack(spacing: 0) {
@@ -40,7 +40,7 @@ struct SendMessageView: View {
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
@UserDefault(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
ZStack {
+1 -1
View File
@@ -78,7 +78,7 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
self.dataSource = UITableViewDiffableDataSource<Section, ChatItem>(
tableView: tableView
) { (tableView, indexPath, item) -> UITableViewCell? in
if indexPath.item > self.itemCount - 8, self.itemCount > 8 {
if indexPath.item > self.itemCount - 8 {
self.representer.loadPage()
}
let cell = tableView.dequeueReusableCell(withIdentifier: cellReuseId, for: indexPath)
@@ -121,9 +121,11 @@ struct ChatListView: View {
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
}
)
.sheet(item: $activeUserPickerSheet) {
UserPickerSheetView(sheet: $0)
}
.appSheet(
item: $activeUserPickerSheet,
onDismiss: { chatModel.laRequest = nil },
content: { UserPickerSheetView(sheet: $0) }
)
.onChange(of: activeUserPickerSheet) {
if $0 != nil {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+7 -5
View File
@@ -11,6 +11,12 @@ import SwiftUI
class AppSheetState: ObservableObject {
static let shared = AppSheetState()
@Published var scenePhaseActive: Bool = false
func redactionReasons(_ protectScreen: Bool) -> RedactionReasons {
!protectScreen || scenePhaseActive
? RedactionReasons()
: RedactionReasons.placeholder
}
}
private struct PrivacySensitive: ViewModifier {
@@ -19,11 +25,7 @@ private struct PrivacySensitive: ViewModifier {
@ObservedObject var appSheetState: AppSheetState = AppSheetState.shared
func body(content: Content) -> some View {
if !protectScreen {
content
} else {
content.privacySensitive(!appSheetState.scenePhaseActive).redacted(reason: .privacy)
}
content.redacted(reason: appSheetState.redactionReasons(protectScreen))
}
}
@@ -16,6 +16,7 @@ struct UserWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
@State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -125,24 +126,27 @@ struct UserWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: { name in editColor(name, theme) })
ImportExportThemeSection(perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: ChatModel.shared.currentUser?.uiThemes)
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
.modifier(
ThemeImporter(isPresented: $showFileImporter) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, nil, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, nil).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
)
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
@@ -216,6 +220,7 @@ struct ChatWallpaperEditor: View {
@State var themeModeOverride: ThemeModeOverride
@State var applyToMode: DefaultThemeMode?
@State var showMore: Bool = false
@State var showFileImporter: Bool = false
@Binding var globalThemeUsed: Bool
var save: (DefaultThemeMode?, ThemeModeOverride?) async -> Void
@@ -328,24 +333,27 @@ struct ChatWallpaperEditor: View {
CustomizeThemeColorsSection(editColor: editColor)
ImportExportThemeSection(perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: themeModeOverride, perUser: ChatModel.shared.currentUser?.uiThemes)
} else {
AdvancedSettingsButton(theme.colors.primary) { showMore = true }
}
}
.modifier(
ThemeImporter(isPresented: $showFileImporter) { imported in
let importedFromString = imported.wallpaper?.importFromString()
let importedType = importedFromString?.toAppWallpaper().type
let currentTheme = ThemeManager.currentColors(nil, nil, ChatModel.shared.currentUser?.uiThemes, themeOverridesDefault.get())
let type: WallpaperType? = if importedType?.sameType(currentTheme.wallpaper.type) == true { nil } else { importedType }
let colors = ThemeManager.currentThemeOverridesForExport(type, nil, ChatModel.shared.currentUser?.uiThemes).colors
let res = ThemeModeOverride(mode: imported.base.mode, colors: imported.colors, wallpaper: importedFromString).removeSameColors(imported.base, colorsToCompare: colors)
Task {
await MainActor.run {
themeModeOverride = res
}
await save(applyToMode, res)
}
}
)
}
private func onTypeCopyFromSameTheme(_ type: WallpaperType?) -> Bool {
@@ -0,0 +1,62 @@
//
// UserDefault.swift
// SimpleX (iOS)
//
// Created by user on 14/10/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import Combine
@propertyWrapper
public struct UserDefault<Value: Equatable>: DynamicProperty {
@StateObject private var observer = UserDefaultObserver()
let initialValue: Value
let key: String
let store: UserDefaults
public init(
wrappedValue: Value,
_ key: String,
store: UserDefaults = .standard
) {
self.initialValue = wrappedValue
self.key = key
self.store = store
}
public var wrappedValue: Value {
get {
// Observer can only be accessed after the property wrapper is installed in view (runtime exception)
observer.subscribe(to: key)
return store.object(forKey: key) as? Value ?? initialValue
}
nonmutating set {
store.set(newValue, forKey: key)
}
}
}
private class UserDefaultObserver: ObservableObject {
private var subscribed = false
func subscribe(to key: String) {
if !subscribed {
NotificationCenter.default.addObserver(
self,
selector: #selector(userDefaultsDidChange),
name: UserDefaults.didChangeNotification,
object: nil
)
subscribed = true
}
}
@objc
private func userDefaultsDidChange(_ notification: Notification) {
Task { @MainActor in objectWillChange.send() }
}
deinit { NotificationCenter.default.removeObserver(self) }
}
+6 -1
View File
@@ -105,7 +105,12 @@ struct TerminalView: View {
}
}
.navigationViewStyle(.stack)
.navigationTitle("Chat console")
.toolbar {
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
ToolbarItem(placement: .principal) {
Text("Chat console").font(.headline)
}
}
.modifier(ThemedBackground())
}
@@ -367,13 +367,13 @@ struct ChatThemePreview: View {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: alice)
.modifier(ChatItemClipped(alice, tailVisible: true))
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: bob)
.modifier(ChatItemClipped(bob, tailVisible: true))
.frame(alignment: .trailing)
}
@@ -583,11 +583,14 @@ struct CustomizeThemeView: View {
}
}
ImportExportThemeSection(perChat: nil, perUser: nil, save: { theme in
ImportExportThemeSection(showFileImporter: $showFileImporter, perChat: nil, perUser: nil)
}
.modifier(
ThemeImporter(isPresented: $showFileImporter) { theme in
ThemeManager.saveAndApplyThemeOverrides(theme)
saveThemeToDatabase(nil)
})
}
}
)
/// When changing app theme, user overrides are hidden. User overrides will be returned back after closing Appearance screen, see ThemeDestinationPicker()
.interactiveDismissDisabled(true)
}
@@ -595,10 +598,9 @@ struct CustomizeThemeView: View {
struct ImportExportThemeSection: View {
@EnvironmentObject var theme: AppTheme
@Binding var showFileImporter: Bool
var perChat: ThemeModeOverride?
var perUser: ThemeModeOverrides?
var save: (ThemeOverrides) -> Void
@State private var showFileImporter = false
var body: some View {
Section {
@@ -626,39 +628,47 @@ struct ImportExportThemeSection: View {
} label: {
Text("Import theme").foregroundColor(theme.colors.primary)
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.data/*.plainText*/],
allowsMultipleSelection: false
) { result in
if case let .success(files) = result, let fileURL = files.first {
do {
var fileSize: Int? = nil
if fileURL.startAccessingSecurityScopedResource() {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
fileSize = resourceValues.fileSize
}
if let fileSize = fileSize,
// Same as Android/desktop
fileSize <= 5_500_000 {
if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
save(theme)
logger.error("Saved theme from file")
} else {
logger.error("Error decoding theme file")
}
fileURL.stopAccessingSecurityScopedResource()
} else {
fileURL.stopAccessingSecurityScopedResource()
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
)
}
} catch {
logger.error("Appearance fileImporter error \(error.localizedDescription)")
}
}
}
struct ThemeImporter: ViewModifier {
@Binding var isPresented: Bool
var save: (ThemeOverrides) -> Void
func body(content: Content) -> some View {
content.fileImporter(
isPresented: $isPresented,
allowedContentTypes: [.data/*.plainText*/],
allowsMultipleSelection: false
) { result in
if case let .success(files) = result, let fileURL = files.first {
do {
var fileSize: Int? = nil
if fileURL.startAccessingSecurityScopedResource() {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
fileSize = resourceValues.fileSize
}
if let fileSize = fileSize,
// Same as Android/desktop
fileSize <= 5_500_000 {
if let string = try? String(contentsOf: fileURL, encoding: .utf8), let theme: ThemeOverrides = decodeYAML("themeId: \(UUID().uuidString)\n" + string) {
save(theme)
logger.error("Saved theme from file")
} else {
logger.error("Error decoding theme file")
}
fileURL.stopAccessingSecurityScopedResource()
} else {
fileURL.stopAccessingSecurityScopedResource()
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: 5_500_000, countStyle: .binary)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Currently maximum supported file size is \(prettyMaxFileSize)."
)
}
} catch {
logger.error("Appearance fileImporter error \(error.localizedDescription)")
}
}
}
@@ -331,7 +331,12 @@ struct SettingsView: View {
chatDatabaseRow()
NavigationLink {
MigrateFromDevice(showProgressOnSettings: $showProgress)
.navigationTitle("Migrate device")
.toolbar {
// Redaction broken for `.navigationTitle` - using a toolbar item instead.
ToolbarItem(placement: .principal) {
Text("Migrate device").font(.headline)
}
}
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
+358 -138
View File
@@ -26,14 +26,52 @@ enum NSENotification {
case nse(UNMutableNotificationContent)
case callkit(RcvCallInvitation)
case empty
case msgInfo(NtfMsgAckInfo)
}
var isCallInvitation: Bool {
public enum NSENotificationData {
case connectionEvent(_ user: User, _ connEntity: ConnectionEntity)
case contactConnected(_ user: any UserLike, _ contact: Contact)
case contactRequest(_ user: any UserLike, _ contactRequest: UserContactRequest)
case messageReceived(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem)
case callInvitation(_ invitation: RcvCallInvitation)
case msgInfo(NtfMsgAckInfo)
case noNtf
var callInvitation: RcvCallInvitation? {
switch self {
case let .nse(ntf): ntf.categoryIdentifier == ntfCategoryCallInvitation
case .callkit: true
case .empty: false
case .msgInfo: false
case let .callInvitation(invitation): invitation
default: nil
}
}
func notificationContent(_ badgeCount: Int) -> UNMutableNotificationContent {
return switch self {
case let .connectionEvent(user, connEntity): createConnectionEventNtf(user, connEntity, badgeCount)
case let .contactConnected(user, contact): createContactConnectedNtf(user, contact, badgeCount)
case let .contactRequest(user, contactRequest): createContactRequestNtf(user, contactRequest, badgeCount)
case let .messageReceived(user, cInfo, cItem): createMessageReceivedNtf(user, cInfo, cItem, badgeCount)
case let .callInvitation(invitation): createCallInvitationNtf(invitation, badgeCount)
case .msgInfo: UNMutableNotificationContent()
case .noNtf: UNMutableNotificationContent()
}
}
var notificationEvent: NSENotificationData? {
return switch self {
case .connectionEvent: self
case .contactConnected: self
case .contactRequest: self
case .messageReceived: self
case .callInvitation: self
case .msgInfo: nil
case .noNtf: nil
}
}
var newMsgData: (any UserLike, ChatInfo)? {
return switch self {
case let .messageReceived(user, cInfo, _): (user, cInfo)
default: nil
}
}
}
@@ -43,9 +81,10 @@ enum NSENotification {
// or when background notification is received.
class NSEThreads {
static let shared = NSEThreads()
private static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
static let queue = DispatchQueue(label: "chat.simplex.app.SimpleX-NSE.notification-threads.lock")
private var allThreads: Set<UUID> = []
private var activeThreads: [(UUID, NotificationService)] = []
var activeThreads: [(UUID, NotificationService)] = []
var droppedNotifications: [(ChatId, NSENotificationData)] = []
func newThread() -> UUID {
NSEThreads.queue.sync {
@@ -64,22 +103,19 @@ class NSEThreads {
}
}
func processNotification(_ id: ChatId, _ ntf: NSENotification) async -> Void {
var waitTime: Int64 = 5_000_000000
while waitTime > 0 {
if let (_, nse) = rcvEntityThread(id),
nse.shouldProcessNtf && nse.processReceivedNtf(ntf) {
break
} else {
try? await Task.sleep(nanoseconds: 10_000000)
waitTime -= 10_000000
}
func processNotification(_ id: ChatId, _ ntf: NSENotificationData) async -> Void {
if let (_, nse) = rcvEntityThread(id),
nse.expectedMessages[id]?.shouldProcessNtf ?? false {
nse.processReceivedNtf(id, ntf, signalReady: true)
}
}
private func rcvEntityThread(_ id: ChatId) -> (UUID, NotificationService)? {
NSEThreads.queue.sync {
activeThreads.first(where: { (_, nse) in nse.receiveEntityId == id })
// this selects the earliest thread that:
// 1) has this connection in nse.expectedMessages
// 2) has not completed processing messages for this connection (not ready)
activeThreads.first(where: { (_, nse) in nse.expectedMessages[id]?.ready == false })
}
}
@@ -106,31 +142,38 @@ class NSEThreads {
}
}
struct ExpectedMessage {
var ntfConn: UserNtfConn
var receiveConnId: String?
var expectedMsgId: String?
var allowedGetNextAttempts: Int
var msgBestAttemptNtf: NSENotificationData?
var ready: Bool
var shouldProcessNtf: Bool
var startedProcessingNewMsgs: Bool
var semaphore: DispatchSemaphore
}
// Notification service extension creates a new instance of the class and calls didReceive for each notification.
// Each didReceive is called in its own thread, but multiple calls can be made in one process, and, empirically, there is never
// more than one process of notification service extension exists at a time.
// Soon after notification service delivers the last notification it is either suspended or terminated.
class NotificationService: UNNotificationServiceExtension {
var contentHandler: ((UNNotificationContent) -> Void)?
var bestAttemptNtf: NSENotification?
// served as notification if no message attempts (msgBestAttemptNtf) could be produced
var serviceBestAttemptNtf: NSENotification?
var badgeCount: Int = 0
// thread is added to allThreads here - if thread did not start chat,
// chat does not need to be suspended but NSE state still needs to be set to "suspended".
var threadId: UUID? = NSEThreads.shared.newThread()
var notificationInfo: NtfMessages?
var receiveEntityId: String?
var receiveConnId: String?
var expectedMessage: String?
var allowedGetNextAttempts: Int = 3
// return true if the message is taken - it prevents sending it to another NotificationService instance for processing
var shouldProcessNtf = false
var expectedMessages: Dictionary<String, ExpectedMessage> = [:] // key is receiveEntityId
var appSubscriber: AppSubscriber?
var returnedSuspension = false
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
logger.debug("DEBUGGING: NotificationService.didReceive")
let ntf = if let ntf_ = request.content.mutableCopy() as? UNMutableNotificationContent { ntf_ } else { UNMutableNotificationContent() }
setBestAttemptNtf(ntf)
setServiceBestAttemptNtf(ntf)
self.contentHandler = contentHandler
registerGroupDefaults()
let appState = appStateGroupDefault.get()
@@ -138,13 +181,11 @@ class NotificationService: UNNotificationServiceExtension {
switch appState {
case .stopped:
setBadgeCount()
setBestAttemptNtf(createAppStoppedNtf())
setServiceBestAttemptNtf(createAppStoppedNtf(badgeCount))
deliverBestAttemptNtf()
case .suspended:
setBadgeCount()
receiveNtfMessages(request, contentHandler)
case .suspending:
setBadgeCount()
Task {
let state: AppState = await withCheckedContinuation { cont in
appSubscriber = appStateSubscriber { s in
@@ -171,8 +212,9 @@ class NotificationService: UNNotificationServiceExtension {
deliverBestAttemptNtf()
}
}
default:
deliverBestAttemptNtf()
case .active: contentHandler(UNMutableNotificationContent())
case .activating: contentHandler(UNMutableNotificationContent())
case .bgRefresh: contentHandler(UNMutableNotificationContent())
}
}
@@ -192,78 +234,165 @@ class NotificationService: UNNotificationServiceExtension {
if let t = threadId { NSEThreads.shared.startThread(t, self) }
let dbStatus = startChat()
if case .ok = dbStatus,
let ntfInfo = apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo) {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfInfo.receivedMsg_ == nil ? 0 : 1))")
if let connEntity = ntfInfo.connEntity_ {
setBestAttemptNtf(
ntfInfo.ntfsEnabled
? .nse(createConnectionEventNtf(ntfInfo.user, connEntity))
: .empty
)
if let id = connEntity.id, ntfInfo.expectedMsg_ != nil {
notificationInfo = ntfInfo
receiveEntityId = id
receiveConnId = connEntity.conn.agentConnId
let expectedMsgId = ntfInfo.expectedMsg_?.msgId
let receivedMsgId = ntfInfo.receivedMsg_?.msgId
logger.debug("NotificationService: receiveNtfMessages: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private), receivedMsgId = \(receivedMsgId ?? "nil", privacy: .private)")
expectedMessage = expectedMsgId
shouldProcessNtf = true
return
let ntfConns = apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo) {
logger.debug("NotificationService: receiveNtfMessages: apiGetNtfConns ntfConns count = \(ntfConns.count)")
for ntfConn in ntfConns {
addExpectedMessage(ntfConn: ntfConn)
}
let connIdsToGet = expectedMessages.compactMap { (id, _) in
let started = NSEThreads.queue.sync {
let canStart = checkCanStart(id)
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: can start: \(canStart)") }
if canStart {
processDroppedNotifications(id)
expectedMessages[id]?.startedProcessingNewMsgs = true
expectedMessages[id]?.shouldProcessNtf = true
}
return canStart
}
if started {
return expectedMessages[id]?.receiveConnId
} else {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) waiting on semaphore") }
expectedMessages[id]?.semaphore.wait()
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): receiveNtfMessages: entity \(id, privacy: .private) proceeding after semaphore") }
Task {
NSEThreads.queue.sync {
processDroppedNotifications(id)
expectedMessages[id]?.startedProcessingNewMsgs = true
expectedMessages[id]?.shouldProcessNtf = true
}
if let connId = expectedMessages[id]?.receiveConnId {
let _ = getConnNtfMessage(connId: connId)
}
}
return nil
}
}
if !connIdsToGet.isEmpty {
if let r = apiGetConnNtfMessages(connIds: connIdsToGet) {
logger.debug("NotificationService: receiveNtfMessages: apiGetConnNtfMessages count = \(r.count)")
}
return
}
} else if let dbStatus = dbStatus {
setBestAttemptNtf(createErrorNtf(dbStatus))
setServiceBestAttemptNtf(createErrorNtf(dbStatus, badgeCount))
}
}
deliverBestAttemptNtf()
}
func addExpectedMessage(ntfConn: UserNtfConn) {
if let connEntity = ntfConn.connEntity_,
let receiveEntityId = connEntity.id, ntfConn.expectedMsg_ != nil {
let expectedMsgId = ntfConn.expectedMsg_?.msgId
logger.debug("NotificationService: addExpectedMessage: expectedMsgId = \(expectedMsgId ?? "nil", privacy: .private)")
expectedMessages[receiveEntityId] = ExpectedMessage(
ntfConn: ntfConn,
receiveConnId: connEntity.conn.agentConnId,
expectedMsgId: expectedMsgId,
allowedGetNextAttempts: 3,
msgBestAttemptNtf: ntfConn.defaultBestAttemptNtf,
ready: false,
shouldProcessNtf: false,
startedProcessingNewMsgs: false,
semaphore: DispatchSemaphore(value: 0)
)
}
}
func checkCanStart(_ entityId: String) -> Bool {
return !NSEThreads.shared.activeThreads.contains(where: {
(tId, nse) in tId != threadId && nse.expectedMessages.contains(where: { $0.key == entityId })
})
}
func processDroppedNotifications(_ entityId: String) {
if !NSEThreads.shared.droppedNotifications.isEmpty {
let messagesToProcess = NSEThreads.shared.droppedNotifications.filter { (eId, _) in eId == entityId }
NSEThreads.shared.droppedNotifications.removeAll(where: { (eId, _) in eId == entityId })
for (index, (_, ntf)) in messagesToProcess.enumerated() {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entity \(entityId, privacy: .private): processing dropped notification \(index, privacy: .private)") }
processReceivedNtf(entityId, ntf, signalReady: false)
}
}
}
override func serviceExtensionTimeWillExpire() {
logger.debug("DEBUGGING: NotificationService.serviceExtensionTimeWillExpire")
deliverBestAttemptNtf(urgent: true)
}
func processReceivedNtf(_ ntf: NSENotification) -> Bool {
guard let ntfInfo = notificationInfo, let expectedMsgTs = ntfInfo.expectedMsg_?.msgTs else { return false }
if !ntfInfo.user.showNotifications {
self.setBestAttemptNtf(.empty)
var expectingMoreMessages: Bool {
!expectedMessages.allSatisfy { $0.value.ready }
}
func processReceivedNtf(_ id: ChatId, _ ntf: NSENotificationData, signalReady: Bool) {
guard let expectedMessage = expectedMessages[id] else {
return
}
guard let expectedMsgTs = expectedMessage.ntfConn.expectedMsg_?.msgTs else {
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
return
}
if case let .msgInfo(info) = ntf {
if info.msgId == expectedMessage {
expectedMessage = nil
if info.msgId == expectedMessage.expectedMsgId {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): expected")
expectedMessages[id]?.expectedMsgId = nil
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return true
} else if let msgTs = info.msgTs_, msgTs > expectedMsgTs {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, let other instance to process it, stopping this one")
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return false
} else if allowedGetNextAttempts > 0, let receiveConnId = receiveConnId {
} else if (expectedMessages[id]?.allowedGetNextAttempts ?? 0) > 0, let receiveConnId = expectedMessages[id]?.receiveConnId {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unexpected msgInfo, get next message")
allowedGetNextAttempts -= 1
if let receivedMsg = apiGetConnNtfMessage(connId: receiveConnId) {
logger.debug("NotificationService processNtf, on apiGetConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)")
return true
expectedMessages[id]?.allowedGetNextAttempts -= 1
if let receivedMsg = getConnNtfMessage(connId: receiveConnId) {
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private), receivedMsg msgId = \(receivedMsg.msgId, privacy: .private)")
} else {
logger.debug("NotificationService processNtf, on apiGetConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt")
logger.debug("NotificationService processNtf, on getConnNtfMessage: msgInfo msgId = \(info.msgId, privacy: .private): no next message, deliver best attempt")
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return false
}
} else {
logger.debug("NotificationService processNtf: msgInfo msgId = \(info.msgId, privacy: .private): unknown message, let other instance to process it")
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
self.deliverBestAttemptNtf()
return false
}
} else if ntfInfo.user.showNotifications {
} else if expectedMessage.ntfConn.user.showNotifications {
logger.debug("NotificationService processNtf: setting best attempt")
self.setBestAttemptNtf(ntf)
if ntf.isCallInvitation {
self.deliverBestAttemptNtf()
if ntf.notificationEvent != nil {
setBadgeCount()
}
return true
let prevBestAttempt = expectedMessages[id]?.msgBestAttemptNtf
if prevBestAttempt?.callInvitation != nil {
if ntf.callInvitation != nil { // replace with newer call
expectedMessages[id]?.msgBestAttemptNtf = ntf
} // otherwise keep call as best attempt
} else {
expectedMessages[id]?.msgBestAttemptNtf = ntf
}
} else {
NSEThreads.shared.droppedNotifications.append((id, ntf))
if signalReady { entityReady(id) }
}
}
func entityReady(_ entityId: ChatId) {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: entity \(entityId, privacy: .private)") }
expectedMessages[entityId]?.ready = true
if let (tNext, nse) = NSEThreads.shared.activeThreads.first(where: { (_, nse) in nse.expectedMessages[entityId]?.startedProcessingNewMsgs == false }) {
if let t = threadId { logger.debug("NotificationService thread \(t, privacy: .private): entityReady: signal next thread \(tNext, privacy: .private) for entity \(entityId, privacy: .private)") }
nse.expectedMessages[entityId]?.semaphore.signal()
}
return false
}
func setBadgeCount() {
@@ -271,37 +400,32 @@ class NotificationService: UNNotificationServiceExtension {
ntfBadgeCountGroupDefault.set(badgeCount)
}
func setBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
setBestAttemptNtf(.nse(ntf))
}
func setBestAttemptNtf(_ ntf: NSENotification) {
logger.debug("NotificationService.setBestAttemptNtf")
if case let .nse(notification) = ntf {
notification.badge = badgeCount as NSNumber
bestAttemptNtf = .nse(notification)
} else {
bestAttemptNtf = ntf
}
func setServiceBestAttemptNtf(_ ntf: UNMutableNotificationContent) {
logger.debug("NotificationService.setServiceBestAttemptNtf")
serviceBestAttemptNtf = .nse(ntf)
}
private func deliverBestAttemptNtf(urgent: Bool = false) {
logger.debug("NotificationService.deliverBestAttemptNtf")
// stop processing other messages
shouldProcessNtf = false
if (urgent || !expectingMoreMessages) {
logger.debug("NotificationService.deliverBestAttemptNtf")
// stop processing other messages
for (key, _) in expectedMessages {
expectedMessages[key]?.shouldProcessNtf = false
}
let suspend: Bool
if let t = threadId {
threadId = nil
suspend = NSEThreads.shared.endThread(t) && NSEThreads.shared.noThreads
} else {
suspend = false
let suspend: Bool
if let t = threadId {
threadId = nil
suspend = NSEThreads.shared.endThread(t) && NSEThreads.shared.noThreads
} else {
suspend = false
}
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
}
deliverCallkitOrNotification(urgent: urgent, suspend: suspend)
}
private func deliverCallkitOrNotification(urgent: Bool, suspend: Bool = false) {
if case .callkit = bestAttemptNtf {
if useCallKit() && expectedMessages.contains(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }) {
logger.debug("NotificationService.deliverCallkitOrNotification: will suspend, callkit")
if urgent {
// suspending NSE even though there may be other notifications
@@ -339,19 +463,13 @@ class NotificationService: UNNotificationServiceExtension {
}
private func deliverNotification() {
if let handler = contentHandler, let ntf = bestAttemptNtf {
if let handler = contentHandler, let ntf = prepareNotification() {
contentHandler = nil
bestAttemptNtf = nil
let deliver: (UNMutableNotificationContent?) -> Void = { ntf in
let useNtf = if let ntf = ntf {
appStateGroupDefault.get().running ? UNMutableNotificationContent() : ntf
} else {
UNMutableNotificationContent()
}
handler(useNtf)
}
serviceBestAttemptNtf = nil
switch ntf {
case let .nse(content): deliver(content)
case let .nse(content):
content.badge = badgeCount as NSNumber
handler(content)
case let .callkit(invitation):
logger.debug("NotificationService reportNewIncomingVoIPPushPayload for \(invitation.contact.id)")
CXProvider.reportNewIncomingVoIPPushPayload([
@@ -362,13 +480,85 @@ class NotificationService: UNNotificationServiceExtension {
"callTs": invitation.callTs.timeIntervalSince1970
]) { error in
logger.debug("reportNewIncomingVoIPPushPayload result: \(error)")
deliver(error == nil ? nil : createCallInvitationNtf(invitation))
handler(error == nil ? UNMutableNotificationContent() : createCallInvitationNtf(invitation, self.badgeCount))
}
case .empty: deliver(nil) // used to mute notifications that did not unsubscribe yet
case .msgInfo: deliver(nil) // unreachable, the best attempt is never set to msgInfo
case .empty:
handler(UNMutableNotificationContent()) // used to mute notifications that did not unsubscribe yet
}
}
}
private func prepareNotification() -> NSENotification? {
if expectedMessages.isEmpty {
return serviceBestAttemptNtf
} else if let callNtfKV = expectedMessages.first(where: { $0.value.msgBestAttemptNtf?.callInvitation != nil }),
let callInv = callNtfKV.value.msgBestAttemptNtf?.callInvitation,
let callNtf = callNtfKV.value.msgBestAttemptNtf {
return useCallKit() ? .callkit(callInv) : .nse(callNtf.notificationContent(badgeCount))
} else {
let ntfEvents = expectedMessages.compactMap { $0.value.msgBestAttemptNtf?.notificationEvent }
if ntfEvents.isEmpty {
return .empty
} else if let ntfEvent = ntfEvents.count == 1 ? ntfEvents.first : nil {
return .nse(ntfEvent.notificationContent(badgeCount))
} else {
return .nse(createJointNtf(ntfEvents))
}
}
}
private func createJointNtf(_ ntfEvents: [NSENotificationData]) -> UNMutableNotificationContent {
let previewMode = ntfPreviewModeGroupDefault.get()
let newMsgsData: [(any UserLike, ChatInfo)] = ntfEvents.compactMap { $0.newMsgData }
if !newMsgsData.isEmpty, let userId = newMsgsData.first?.0.userId {
let newMsgsChats: [ChatInfo] = newMsgsData.map { $0.1 }
let uniqueChatsNames = uniqueNewMsgsChatsNames(newMsgsChats)
var body: String
if previewMode == .hidden {
body = String.localizedStringWithFormat(NSLocalizedString("New messages in %d chats", comment: "notification body"), uniqueChatsNames.count)
} else {
body = String.localizedStringWithFormat(NSLocalizedString("From: %@", comment: "notification body"), newMsgsChatsNamesStr(uniqueChatsNames))
}
return createNotification(
categoryIdentifier: ntfCategoryManyEvents,
title: NSLocalizedString("New messages", comment: "notification"),
body: body,
userInfo: ["userId": userId],
badgeCount: badgeCount
)
} else {
return createNotification(
categoryIdentifier: ntfCategoryManyEvents,
title: NSLocalizedString("New events", comment: "notification"),
body: String.localizedStringWithFormat(NSLocalizedString("%d new events", comment: "notification body"), ntfEvents.count),
badgeCount: badgeCount
)
}
}
private func uniqueNewMsgsChatsNames(_ newMsgsChats: [ChatInfo]) -> [String] {
var seenChatIds = Set<ChatId>()
var uniqueChatsNames: [String] = []
for chat in newMsgsChats {
if !seenChatIds.contains(chat.id) {
seenChatIds.insert(chat.id)
uniqueChatsNames.append(chat.chatViewName)
}
}
return uniqueChatsNames
}
private func newMsgsChatsNamesStr(_ names: [String]) -> String {
return switch names.count {
case 1: names[0]
case 2: "\(names[0]) and \(names[1])"
case 3: "\(names[0] + ", " + names[1]) and \(names[2])"
default:
names.count > 3
? "\(names[0]), \(names[1]) and \(names.count - 2) other chats"
: ""
}
}
}
// nseStateGroupDefault must not be used in NSE directly, only via this singleton
@@ -582,28 +772,25 @@ func chatRecvMsg() async -> ChatResponse? {
private let isInChina = SKStorefront().countryCode == "CHN"
private func useCallKit() -> Bool { !isInChina && callKitEnabledGroupDefault.get() }
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotificationData)? {
logger.debug("NotificationService receivedMsgNtf: \(res.responseType)")
switch res {
case let .contactConnected(user, contact, _):
return (contact.id, .nse(createContactConnectedNtf(user, contact)))
return (contact.id, .contactConnected(user, contact))
// case let .contactConnecting(contact):
// TODO profile update
case let .receivedContactRequest(user, contactRequest):
return (UserContact(contactRequest: contactRequest).id, .nse(createContactRequestNtf(user, contactRequest)))
return (UserContact(contactRequest: contactRequest).id, .contactRequest(user, contactRequest))
case let .newChatItems(user, chatItems):
// Received items are created one at a time
if let chatItem = chatItems.first {
let cInfo = chatItem.chatInfo
var cItem = chatItem.chatItem
if !cInfo.ntfsEnabled {
ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1))
}
if let file = cItem.autoReceiveFile() {
cItem = autoReceiveFile(file) ?? cItem
}
let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(createMessageReceivedNtf(user, cInfo, cItem)) : .empty
return cItem.showNotification ? (chatItem.chatId, ntf) : nil
let ntf: NSENotificationData = (cInfo.ntfsEnabled && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf
return (chatItem.chatId, ntf)
} else {
return nil
}
@@ -620,10 +807,7 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? {
return nil
case let .callInvitation(invitation):
// Do not post it without CallKit support, iOS will stop launching the app without showing CallKit
return (
invitation.contact.id,
useCallKit() ? .callkit(invitation) : .nse(createCallInvitationNtf(invitation))
)
return (invitation.contact.id, .callInvitation(invitation))
case let .ntfMessage(_, connEntity, ntfMessage):
return if let id = connEntity.id { (id, .msgInfo(ntfMessage)) } else { nil }
case .chatSuspended:
@@ -704,15 +888,15 @@ func apiSetEncryptLocalFiles(_ enable: Bool) throws {
throw r
}
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
func apiGetNtfConns(nonce: String, encNtfInfo: String) -> [UserNtfConn]? {
guard apiGetActiveUser() != nil else {
logger.debug("no active user")
return nil
}
let r = sendSimpleXCmd(.apiGetNtfMessage(nonce: nonce, encNtfInfo: encNtfInfo))
if case let .ntfMessages(user, connEntity_, expectedMsg_, receivedMsg_) = r, let user = user {
logger.debug("apiGetNtfMessage response ntfMessages: \(receivedMsg_ == nil ? 0 : 1)")
return NtfMessages(user: user, connEntity_: connEntity_, expectedMsg_: expectedMsg_, receivedMsg_: receivedMsg_)
let r = sendSimpleXCmd(.apiGetNtfConns(nonce: nonce, encNtfInfo: encNtfInfo))
if case let .ntfConns(ntfConns) = r {
logger.debug("apiGetNtfConns response ntfConns: \(ntfConns.count)")
return ntfConns.compactMap { toUserNtfConn($0) }
} else if case let .chatCmdError(_, error) = r {
logger.debug("apiGetNtfMessage error response: \(String.init(describing: error))")
} else {
@@ -721,17 +905,33 @@ func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
return nil
}
func apiGetConnNtfMessage(connId: String) -> NtfMsgInfo? {
func toUserNtfConn(_ ntfConn: NtfConn) -> UserNtfConn? {
if let user = ntfConn.user_ {
return UserNtfConn(user: user, connEntity_: ntfConn.connEntity_, expectedMsg_: ntfConn.expectedMsg_)
} else {
return nil
}
}
func apiGetConnNtfMessages(connIds: [String]) -> [NtfMsgInfo?]? {
guard apiGetActiveUser() != nil else {
logger.debug("no active user")
return nil
}
let r = sendSimpleXCmd(.apiGetConnNtfMessage(connId: connId))
if case let .connNtfMessage(receivedMsg_) = r {
logger.debug("apiGetConnNtfMessage response receivedMsg_: \(receivedMsg_ == nil ? 0 : 1)")
return receivedMsg_
let r = sendSimpleXCmd(.apiGetConnNtfMessages(connIds: connIds))
if case let .connNtfMessages(receivedMsgs) = r {
logger.debug("apiGetConnNtfMessages response receivedMsgs: \(receivedMsgs.count)")
return receivedMsgs
}
logger.debug("apiGetConnNtfMessages error: \(responseError(r))")
return nil
}
func getConnNtfMessage(connId: String) -> NtfMsgInfo? {
let r_ = apiGetConnNtfMessages(connIds: [connId])
if let r = r_, let receivedMsg = r.count == 1 ? r.first : nil {
return receivedMsg
}
logger.debug("apiGetConnNtfMessage error: \(responseError(r))")
return nil
}
@@ -769,13 +969,33 @@ func setNetworkConfig(_ cfg: NetCfg) throws {
throw r
}
struct NtfMessages {
struct UserNtfConn {
var user: User
var connEntity_: ConnectionEntity?
var expectedMsg_: NtfMsgInfo?
var receivedMsg_: NtfMsgInfo?
var ntfsEnabled: Bool {
user.showNotifications && (connEntity_?.ntfsEnabled ?? false)
var defaultBestAttemptNtf: NSENotificationData {
return if !user.showNotifications {
.noNtf
} else if let connEntity = connEntity_ {
switch connEntity {
case let .rcvDirectMsgConnection(_, contact):
contact?.chatSettings.enableNtfs == .all
? .connectionEvent(user, connEntity)
: .noNtf
case let .rcvGroupMsgConnection(_, groupInfo, _):
groupInfo.chatSettings.enableNtfs == .all
? .connectionEvent(user, connEntity)
: .noNtf
case .sndFileConnection: .noNtf
case .rcvFileConnection: .noNtf
case let .userContactConnection(_, userContact):
userContact.groupId == nil
? .connectionEvent(user, connEntity)
: .noNtf
}
} else {
.noNtf
}
}
}
+57 -45
View File
@@ -148,6 +148,11 @@
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B402CCBEB080083A2CF /* libgmpxx.a */; };
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */; };
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B422CCBEB080083A2CF /* libffi.a */; };
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */; };
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 643B3B442CCBEB080083A2CF /* libgmp.a */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; };
@@ -204,6 +209,7 @@
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = CE38A29B2C3FCD72005ED185 /* SwiftyGif */; };
CE75480A2C622630009579B7 /* SwipeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE7548092C622630009579B7 /* SwipeLabel.swift */; };
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */; };
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */; };
CEDB245B2C9CD71800FBC5F6 /* StickyScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */; };
CEDE70222C48FD9500233B1F /* SEChatState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEDE70212C48FD9500233B1F /* SEChatState.swift */; };
CEE723AA2C3BD3D70009AE93 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */; };
@@ -223,11 +229,6 @@
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
E5DCF9982C5906FF007928CC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9962C5906FF007928CC /* InfoPlist.strings */; };
E5E997C92CBA891A00D7A2FA /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C42CBA891A00D7A2FA /* libgmpxx.a */; };
E5E997CA2CBA891A00D7A2FA /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C52CBA891A00D7A2FA /* libgmp.a */; };
E5E997CB2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C62CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a */; };
E5E997CC2CBA891A00D7A2FA /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C72CBA891A00D7A2FA /* libffi.a */; };
E5E997CD2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E997C82CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -490,6 +491,11 @@
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
643B3B402CCBEB080083A2CF /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmpxx.a; path = Libraries/libgmpxx.a; sourceTree = "<group>"; };
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a"; sourceTree = "<group>"; };
643B3B422CCBEB080083A2CF /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libffi.a; path = Libraries/libffi.a; sourceTree = "<group>"; };
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = "libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; path = "Libraries/libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a"; sourceTree = "<group>"; };
643B3B442CCBEB080083A2CF /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = libgmp.a; path = Libraries/libgmp.a; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = "<group>"; };
@@ -545,6 +551,7 @@
CE3097FA2C4C0C9F00180898 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = "<group>"; };
CE7548092C622630009579B7 /* SwipeLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeLabel.swift; sourceTree = "<group>"; };
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemClipShape.swift; sourceTree = "<group>"; };
CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefault.swift; sourceTree = "<group>"; };
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyScrollView.swift; sourceTree = "<group>"; };
CEDE70212C48FD9500233B1F /* SEChatState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SEChatState.swift; sourceTree = "<group>"; };
CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX SE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -612,11 +619,6 @@
E5DCF9A62C590731007928CC /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5DCF9A72C590732007928CC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5DCF9A82C590732007928CC /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/InfoPlist.strings; sourceTree = "<group>"; };
E5E997C42CBA891A00D7A2FA /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
E5E997C52CBA891A00D7A2FA /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
E5E997C62CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a"; sourceTree = "<group>"; };
E5E997C72CBA891A00D7A2FA /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
E5E997C82CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -655,14 +657,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E5E997C92CBA891A00D7A2FA /* libgmpxx.a in Frameworks */,
E5E997CB2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a in Frameworks */,
E5E997CA2CBA891A00D7A2FA /* libgmp.a in Frameworks */,
643B3B452CCBEB080083A2CF /* libgmpxx.a in Frameworks */,
643B3B472CCBEB080083A2CF /* libffi.a in Frameworks */,
643B3B492CCBEB080083A2CF /* libgmp.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
E5E997CC2CBA891A00D7A2FA /* libffi.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
E5E997CD2CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a in Frameworks */,
643B3B482CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a in Frameworks */,
643B3B462CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -739,11 +741,6 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
E5E997C72CBA891A00D7A2FA /* libffi.a */,
E5E997C52CBA891A00D7A2FA /* libgmp.a */,
E5E997C42CBA891A00D7A2FA /* libgmpxx.a */,
E5E997C62CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv-ghc9.6.3.a */,
E5E997C82CBA891A00D7A2FA /* libHSsimplex-chat-6.1.0.9-3X73OucN19a19eYgUK66sv.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -802,6 +799,7 @@
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */,
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */,
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */,
CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -814,6 +812,11 @@
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
5C764E5C279C70B7000C6508 /* Libraries */,
643B3B422CCBEB080083A2CF /* libffi.a */,
643B3B442CCBEB080083A2CF /* libgmp.a */,
643B3B402CCBEB080083A2CF /* libgmpxx.a */,
643B3B412CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5-ghc9.6.3.a */,
643B3B432CCBEB080083A2CF /* libHSsimplex-chat-6.1.1.0-CTfGB7l09cqEHVIdvhrnH5.a */,
5CA059C2279559F40002BEB4 /* Shared */,
5CDCAD462818589900503DA2 /* SimpleX NSE */,
CEE723A82C3BD3D70009AE93 /* SimpleX SE */,
@@ -909,10 +912,9 @@
5CB924DF27A8678B00ACCCDD /* UserSettings */ = {
isa = PBXGroup;
children = (
643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */,
5CB924D627A8563F00ACCCDD /* SettingsView.swift */,
5CB346E62868D76D001FD2EF /* NotificationsView.swift */,
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
5CADE79929211BB900072E13 /* PreferencesView.swift */,
5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */,
5C05DF522840AA1D00C683F9 /* CallSettings.swift */,
@@ -920,9 +922,6 @@
5CC036DF29C488D500C0EF20 /* HiddenProfileView.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
5C3F1D57284363C400EC8A82 /* PrivacySettings.swift */,
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
5CB2084E28DA4B4800D024EC /* RTCServers.swift */,
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */,
18415845648CA4F5A8BCA272 /* UserProfilesView.swift */,
@@ -1053,6 +1052,18 @@
path = Database;
sourceTree = "<group>";
};
643B3B4C2CCFD34B0083A2CF /* NetworkAndServers */ = {
isa = PBXGroup;
children = (
5C9329402929248A0090FFF9 /* ScanProtocolServer.swift */,
5C93293029239BED0090FFF9 /* ProtocolServerView.swift */,
5C93292E29239A170090FFF9 /* ProtocolServersView.swift */,
5C9C2DA6289957AE00CC63B1 /* AdvancedNetworkSettings.swift */,
5C9C2DA82899DA6F00CC63B1 /* NetworkAndServers.swift */,
);
path = NetworkAndServers;
sourceTree = "<group>";
};
6440CA01288AEC770062C672 /* Group */ = {
isa = PBXGroup;
children = (
@@ -1482,6 +1493,7 @@
5CFA59D12864782E00863A68 /* ChatArchiveView.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
5CADE79C292131E900072E13 /* ContactPreferencesView.swift in Sources */,
CEA6E91C2CBD21B0002B5DB4 /* UserDefault.swift in Sources */,
5CB346E52868AA7F001FD2EF /* SuspendChat.swift in Sources */,
5C9C2DA52894777E00CC63B1 /* GroupProfileView.swift in Sources */,
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
@@ -1899,7 +1911,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1924,7 +1936,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1948,7 +1960,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1973,7 +1985,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1989,11 +2001,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2009,11 +2021,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2034,7 +2046,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2049,7 +2061,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2071,7 +2083,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2086,7 +2098,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2108,7 +2120,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2134,7 +2146,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2159,7 +2171,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2185,7 +2197,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2210,7 +2222,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2225,7 +2237,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2244,7 +2256,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 244;
CURRENT_PROJECT_VERSION = 245;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2259,7 +2271,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.1;
MARKETING_VERSION = 6.1.1;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
+13 -12
View File
@@ -55,8 +55,8 @@ public enum ChatCommand {
case apiRegisterToken(token: DeviceToken, notificationMode: NotificationsMode)
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
case apiDeleteToken(token: DeviceToken)
case apiGetNtfMessage(nonce: String, encNtfInfo: String)
case apiGetConnNtfMessage(connId: String)
case apiGetNtfConns(nonce: String, encNtfInfo: String)
case apiGetConnNtfMessages(connIds: [String])
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64)
@@ -214,8 +214,8 @@ public enum ChatCommand {
case let .apiRegisterToken(token, notificationMode): return "/_ntf register \(token.cmdString) \(notificationMode.rawValue)"
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)"
case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)"
case let .apiGetConnNtfMessage(connId): return "/_ntf conn message \(connId)"
case let .apiGetNtfConns(nonce, encNtfInfo): return "/_ntf conns \(nonce) \(encNtfInfo)"
case let .apiGetConnNtfMessages(connIds): return "/_ntf conn messages \(connIds.joined(separator: ","))"
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
@@ -369,8 +369,8 @@ public enum ChatCommand {
case .apiRegisterToken: return "apiRegisterToken"
case .apiVerifyToken: return "apiVerifyToken"
case .apiDeleteToken: return "apiDeleteToken"
case .apiGetNtfMessage: return "apiGetNtfMessage"
case .apiGetConnNtfMessage: return "apiGetConnNtfMessage"
case .apiGetNtfConns: return "apiGetNtfConns"
case .apiGetConnNtfMessages: return "apiGetConnNtfMessages"
case .apiNewGroup: return "apiNewGroup"
case .apiAddMember: return "apiAddMember"
case .apiJoinGroup: return "apiJoinGroup"
@@ -682,8 +682,8 @@ public enum ChatResponse: Decodable, Error {
case callInvitations(callInvitations: [RcvCallInvitation])
case ntfTokenStatus(status: NtfTknStatus)
case ntfToken(token: DeviceToken, status: NtfTknStatus, ntfMode: NotificationsMode, ntfServer: String)
case ntfMessages(user_: User?, connEntity_: ConnectionEntity?, expectedMsg_: NtfMsgInfo?, receivedMsg_: NtfMsgInfo?)
case connNtfMessage(receivedMsg_: NtfMsgInfo?)
case ntfConns(ntfConns: [NtfConn])
case connNtfMessages(receivedMsgs: [NtfMsgInfo?])
case ntfMessage(user: UserRef, connEntity: ConnectionEntity, ntfMessage: NtfMsgAckInfo)
case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection)
case contactDisabled(user: UserRef, contact: Contact)
@@ -851,8 +851,8 @@ public enum ChatResponse: Decodable, Error {
case .callInvitations: return "callInvitations"
case .ntfTokenStatus: return "ntfTokenStatus"
case .ntfToken: return "ntfToken"
case .ntfMessages: return "ntfMessages"
case .connNtfMessage: return "connNtfMessage"
case .ntfConns: return "ntfConns"
case .connNtfMessages: return "connNtfMessages"
case .ntfMessage: return "ntfMessage"
case .contactConnectionDeleted: return "contactConnectionDeleted"
case .contactDisabled: return "contactDisabled"
@@ -1029,8 +1029,8 @@ public enum ChatResponse: Decodable, Error {
case let .callInvitations(invs): return String(describing: invs)
case let .ntfTokenStatus(status): return String(describing: status)
case let .ntfToken(token, status, ntfMode, ntfServer): return "token: \(token)\nstatus: \(status.rawValue)\nntfMode: \(ntfMode.rawValue)\nntfServer: \(ntfServer)"
case let .ntfMessages(u, connEntity, expectedMsg_, receivedMsg_): return withUser(u, "connEntity: \(String(describing: connEntity))\nexpectedMsg_: \(String(describing: expectedMsg_))\nreceivedMsg_: \(String(describing: receivedMsg_))")
case let .connNtfMessage(receivedMsg_): return "receivedMsg_: \(String(describing: receivedMsg_))"
case let .ntfConns(ntfConns): return String(describing: ntfConns)
case let .connNtfMessages(receivedMsgs): return "receivedMsgs: \(String(describing: receivedMsgs))"
case let .ntfMessage(u, connEntity, ntfMessage): return withUser(u, "connEntity: \(String(describing: connEntity))\nntfMessage: \(String(describing: ntfMessage))")
case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection))
case let .contactDisabled(u, contact): return withUser(u, String(describing: contact))
@@ -2088,6 +2088,7 @@ public enum ProtocolErrorType: Decodable, Hashable {
case AUTH
case CRYPTO
case QUOTA
case STORE(storeErr: String)
case NO_MSG
case LARGE_MSG
case EXPIRED
+6 -9
View File
@@ -2270,16 +2270,13 @@ public enum ConnectionEntity: Decodable, Hashable {
case let .userContactConnection(entityConnection, _): entityConnection
}
}
}
public struct NtfConn: Decodable, Hashable {
public var user_: User?
public var connEntity_: ConnectionEntity?
public var expectedMsg_: NtfMsgInfo?
public var ntfsEnabled: Bool {
switch self {
case let .rcvDirectMsgConnection(_, contact): return contact?.chatSettings.enableNtfs == .all
case let .rcvGroupMsgConnection(_, groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all
case .sndFileConnection: return false
case .rcvFileConnection: return false
case let .userContactConnection(_, userContact): return userContact.groupId == nil
}
}
}
public struct NtfMsgInfo: Decodable, Hashable {
+32 -16
View File
@@ -15,13 +15,14 @@ public let ntfCategoryContactConnected = "NTF_CAT_CONTACT_CONNECTED"
public let ntfCategoryMessageReceived = "NTF_CAT_MESSAGE_RECEIVED"
public let ntfCategoryCallInvitation = "NTF_CAT_CALL_INVITATION"
public let ntfCategoryConnectionEvent = "NTF_CAT_CONNECTION_EVENT"
public let ntfCategoryManyEvents = "NTF_CAT_MANY_EVENTS"
public let ntfCategoryCheckMessage = "NTF_CAT_CHECK_MESSAGE"
public let appNotificationId = "chat.simplex.app.notification"
let contactHidden = NSLocalizedString("Contact hidden:", comment: "notification")
public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest) -> UNMutableNotificationContent {
public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: UserContactRequest, _ badgeCount: Int) -> UNMutableNotificationContent {
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
return createNotification(
categoryIdentifier: ntfCategoryContactRequest,
@@ -34,11 +35,12 @@ public func createContactRequestNtf(_ user: any UserLike, _ contactRequest: User
hideContent ? NSLocalizedString("this contact", comment: "notification title") : contactRequest.chatViewName
),
targetContentIdentifier: nil,
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId]
userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId, "userId": user.userId],
badgeCount: badgeCount
)
}
public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact) -> UNMutableNotificationContent {
public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact, _ badgeCount: Int) -> UNMutableNotificationContent {
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
return createNotification(
categoryIdentifier: ntfCategoryContactConnected,
@@ -51,12 +53,13 @@ public func createContactConnectedNtf(_ user: any UserLike, _ contact: Contact)
hideContent ? NSLocalizedString("this contact", comment: "notification title") : contact.chatViewName
),
targetContentIdentifier: contact.id,
userInfo: ["userId": user.userId]
userInfo: ["userId": user.userId],
// userInfo: ["chatId": contact.id, "contactId": contact.apiId]
badgeCount: badgeCount
)
}
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem) -> UNMutableNotificationContent {
public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _ cItem: ChatItem, _ badgeCount: Int) -> UNMutableNotificationContent {
let previewMode = ntfPreviewModeGroupDefault.get()
var title: String
if case let .group(groupInfo) = cInfo, case let .groupRcv(groupMember) = cItem.chatDir {
@@ -69,12 +72,13 @@ public func createMessageReceivedNtf(_ user: any UserLike, _ cInfo: ChatInfo, _
title: title,
body: previewMode == .message ? hideSecrets(cItem) : NSLocalizedString("new message", comment: "notification"),
targetContentIdentifier: cInfo.id,
userInfo: ["userId": user.userId]
userInfo: ["userId": user.userId],
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
badgeCount: badgeCount
)
}
public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutableNotificationContent {
public func createCallInvitationNtf(_ invitation: RcvCallInvitation, _ badgeCount: Int) -> UNMutableNotificationContent {
let text = invitation.callType.media == .video
? NSLocalizedString("Incoming video call", comment: "notification")
: NSLocalizedString("Incoming audio call", comment: "notification")
@@ -84,11 +88,12 @@ public func createCallInvitationNtf(_ invitation: RcvCallInvitation) -> UNMutabl
title: hideContent ? contactHidden : "\(invitation.contact.chatViewName):",
body: text,
targetContentIdentifier: nil,
userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId]
userInfo: ["chatId": invitation.contact.id, "userId": invitation.user.userId],
badgeCount: badgeCount
)
}
public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity) -> UNMutableNotificationContent {
public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntity, _ badgeCount: Int) -> UNMutableNotificationContent {
let hideContent = ntfPreviewModeGroupDefault.get() == .hidden
var title: String
var body: String? = nil
@@ -118,11 +123,12 @@ public func createConnectionEventNtf(_ user: User, _ connEntity: ConnectionEntit
title: title,
body: body,
targetContentIdentifier: targetContentIdentifier,
userInfo: ["userId": user.userId]
userInfo: ["userId": user.userId],
badgeCount: badgeCount
)
}
public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificationContent {
public func createErrorNtf(_ dbStatus: DBMigrationResult, _ badgeCount: Int) -> UNMutableNotificationContent {
var title: String
switch dbStatus {
case .errorNotADatabase:
@@ -142,14 +148,16 @@ public func createErrorNtf(_ dbStatus: DBMigrationResult) -> UNMutableNotificati
}
return createNotification(
categoryIdentifier: ntfCategoryConnectionEvent,
title: title
title: title,
badgeCount: badgeCount
)
}
public func createAppStoppedNtf() -> UNMutableNotificationContent {
public func createAppStoppedNtf(_ badgeCount: Int) -> UNMutableNotificationContent {
return createNotification(
categoryIdentifier: ntfCategoryConnectionEvent,
title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification")
title: NSLocalizedString("Encrypted message: app is stopped", comment: "notification"),
badgeCount: badgeCount
)
}
@@ -159,8 +167,15 @@ private func groupMsgNtfTitle(_ groupInfo: GroupInfo, _ groupMember: GroupMember
: "#\(groupInfo.displayName) \(groupMember.chatViewName):"
}
public func createNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) -> UNMutableNotificationContent {
public func createNotification(
categoryIdentifier: String,
title: String,
subtitle: String? = nil,
body: String? = nil,
targetContentIdentifier: String? = nil,
userInfo: [AnyHashable : Any] = [:],
badgeCount: Int
) -> UNMutableNotificationContent {
let content = UNMutableNotificationContent()
content.categoryIdentifier = categoryIdentifier
content.title = title
@@ -170,6 +185,7 @@ public func createNotification(categoryIdentifier: String, title: String, subtit
content.userInfo = userInfo
// TODO move logic of adding sound here, so it applies to background notifications too
content.sound = .default
content.badge = badgeCount as NSNumber
// content.interruptionLevel = .active
// content.relevanceScore = 0.5 // 0-1
return content
@@ -4,8 +4,10 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.*
import android.view.View
import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.ui.platform.ClipboardManager
import androidx.fragment.app.FragmentActivity
import chat.simplex.app.model.NtfManager
@@ -13,7 +15,6 @@ import chat.simplex.app.model.NtfManager.getUserIdFromIntent
import chat.simplex.common.*
import chat.simplex.common.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
@@ -24,13 +25,21 @@ import kotlinx.coroutines.*
import java.lang.ref.WeakReference
class MainActivity: FragmentActivity() {
companion object {
const val OLD_ANDROID_UI_FLAGS = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
}
override fun onCreate(savedInstanceState: Bundle?) {
mainActivity = WeakReference(this)
platform.androidSetNightModeIfSupported()
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
applyAppLocale(ChatModel.controller.appPrefs.appLanguage)
// This flag makes status bar and navigation bar fully transparent. But on API level < 30 it breaks insets entirely
// https://issuetracker.google.com/issues/236862874
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window.setFlags(WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS)
}
super.onCreate(savedInstanceState)
// testJson()
// When call ended and orientation changes, it re-process old intent, it's unneeded.
@@ -47,6 +56,7 @@ class MainActivity: FragmentActivity() {
WindowManager.LayoutParams.FLAG_SECURE
)
}
enableEdgeToEdge()
setContent {
AppScreen()
}
@@ -7,6 +7,7 @@ import chat.simplex.common.platform.Log
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.*
import android.view.View
import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -16,6 +17,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.core.view.ViewCompat
import androidx.lifecycle.*
import androidx.work.*
import chat.simplex.app.MainActivity.Companion.OLD_ANDROID_UI_FLAGS
import chat.simplex.app.model.NtfManager
import chat.simplex.app.model.NtfManager.AcceptCallAction
import chat.simplex.app.views.call.CallActivity
@@ -26,7 +28,6 @@ import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chatlist.statusBarColorAfterCall
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import com.jakewharton.processphoenix.ProcessPhoenix
@@ -274,79 +275,32 @@ class SimplexApp: Application(), LifecycleEventObserver {
uiModeManager.setApplicationNightMode(mode)
}
override fun androidSetDrawerStatusAndNavBarColor(
isLight: Boolean,
drawerShadingColor: Color,
toolbarOnTop: Boolean,
navBarColor: Color,
) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
// Blend status bar color to the animated color
val colors = CurrentColors.value.colors
val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
var statusBar = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
var statusBarLight = isLight
// SimplexGreen while in call
if (window.statusBarColor == SimplexGreen.toArgb()) {
statusBarColorAfterCall.intValue = statusBar
statusBar = SimplexGreen.toArgb()
statusBarLight = false
}
window.statusBarColor = statusBar
val navBar = navBarColor.toArgb()
if (windowInsetController?.isAppearanceLightStatusBars != statusBarLight) {
windowInsetController?.isAppearanceLightStatusBars = statusBarLight
}
if (window.navigationBarColor != navBar) {
window.navigationBarColor = navBar
}
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
windowInsetController?.isAppearanceLightNavigationBars = isLight
}
}
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
override fun androidSetStatusAndNavigationBarAppearance(isLightStatusBar: Boolean, isLightNavBar: Boolean, blackNavBar: Boolean, themeBackgroundColor: Color) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
val statusLight = isLightStatusBar && chatModel.activeCall.value == null
val navBarLight = isLightNavBar || windowOrientation() == WindowOrientation.LANDSCAPE
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
var statusBar = (if (hasTop && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
backgroundColor.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
} else {
if (CurrentColors.value.base == DefaultTheme.SIMPLEX) {
backgroundColor.lighter(0.4f)
if (windowInsetController?.isAppearanceLightStatusBars != statusLight) {
windowInsetController?.isAppearanceLightStatusBars = statusLight
}
window.navigationBarColor = Color.Transparent.toArgb()
if (windowInsetController?.isAppearanceLightNavigationBars != navBarLight) {
windowInsetController?.isAppearanceLightNavigationBars = navBarLight
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
window.decorView.systemUiVisibility = if (statusLight && navBarLight) {
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR or OLD_ANDROID_UI_FLAGS
} else if (statusLight) {
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or OLD_ANDROID_UI_FLAGS
} else if (navBarLight) {
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR or OLD_ANDROID_UI_FLAGS
} else {
backgroundColor
OLD_ANDROID_UI_FLAGS
}
}).toArgb()
var statusBarLight = isLight
// SimplexGreen while in call
if (window.statusBarColor == SimplexGreen.toArgb()) {
statusBarColorAfterCall.intValue = statusBar
statusBar = SimplexGreen.toArgb()
statusBarLight = false
}
val navBar = (if (hasBottom && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
backgroundColor.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
window.navigationBarColor = if (blackNavBar) Color.Black.toArgb() else themeBackgroundColor.toArgb()
} else {
backgroundColor
}).toArgb()
if (window.statusBarColor != statusBar) {
window.statusBarColor = statusBar
}
if (windowInsetController?.isAppearanceLightStatusBars != statusBarLight) {
windowInsetController?.isAppearanceLightStatusBars = statusBarLight
}
if (window.navigationBarColor != navBar) {
window.navigationBarColor = navBar
}
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
windowInsetController?.isAppearanceLightNavigationBars = isLight
window.navigationBarColor = Color.Transparent.toArgb()
}
}
@@ -401,6 +355,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
}
return true
}
override val androidApiLevel: Int get() = Build.VERSION.SDK_INT
}
}
}
@@ -64,7 +64,6 @@ kotlin {
implementation("androidx.activity:activity-compose:1.9.1")
val workVersion = "2.9.1"
implementation("androidx.work:work-runtime-ktx:$workVersion")
implementation("com.google.accompanist:accompanist-insets:0.30.1")
// Video support
implementation("com.google.android.exoplayer:exoplayer:2.19.1")
@@ -3,25 +3,13 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import com.google.accompanist.insets.navigationBarsWithImePadding
import java.io.File
actual fun Modifier.navigationBarsWithImePadding(): Modifier = navigationBarsWithImePadding()
@Composable
actual fun ProvideWindowInsets(
consumeWindowInsets: Boolean,
windowInsetsAnimationsEnabled: Boolean,
content: @Composable () -> Unit
) {
com.google.accompanist.insets.ProvideWindowInsets(content = content)
}
@Composable
actual fun Modifier.desktopOnExternalDrag(
enabled: Boolean,
onFiles: (List<File>) -> Unit,
onImage: (Painter) -> Unit,
onImage: (File) -> Unit,
onText: (String) -> Unit
): Modifier = this
@@ -6,8 +6,7 @@ import android.graphics.drawable.ColorDrawable
import android.os.Build
import android.text.InputType
import android.util.Log
import android.view.OnReceiveContentListener
import android.view.ViewGroup
import android.view.*
import android.view.inputmethod.*
import android.widget.EditText
import android.widget.TextView
@@ -26,6 +25,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.children
import androidx.core.view.inputmethod.EditorInfoCompat
import androidx.core.view.inputmethod.InputConnectionCompat
import androidx.core.widget.doAfterTextChanged
@@ -94,8 +94,8 @@ actual fun PlatformTextField(
}
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
AndroidView(modifier = Modifier, factory = {
val editText = @SuppressLint("AppCompatCustomView") object: EditText(it) {
AndroidView(modifier = Modifier, factory = { context ->
val editText = @SuppressLint("AppCompatCustomView") object: EditText(context) {
override fun setOnReceiveContentListener(
mimeTypes: Array<out String>?,
listener: OnReceiveContentListener?
@@ -140,6 +140,13 @@ actual fun PlatformTextField(
Log.e(TAG, e.stackTraceToString())
}
}
editText.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
// shows keyboard when user had search field on ChatView focused before clicking on this text field
// it still produce weird animation of closing/opening keyboard but the solution is to replace this Android EditText with Compose BasicTextField
if (hasFocus) {
showKeyboard = true
}
}
editText.doOnTextChanged { text, _, _, _ ->
if (!composeState.value.inProgress) {
onMessageChange(text.toString())
@@ -148,8 +155,12 @@ actual fun PlatformTextField(
}
}
editText.doAfterTextChanged { text -> if (composeState.value.preview is ComposePreview.VoicePreview && text.toString() != "") editText.setText("") }
editText
val workaround = WorkaroundFocusSearchLayout(context)
workaround.addView(editText)
workaround.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
workaround
}) {
val it = it.children.first() as EditText
it.setTextColor(textColor.toArgb())
it.setHintTextColor(hintColor.toArgb())
it.hint = placeholder
@@ -6,11 +6,11 @@ import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import android.text.BidiFormatter
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
@@ -50,7 +50,11 @@ actual fun windowOrientation(): WindowOrientation = when (mainActivity.get()?.re
}
@Composable
actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
actual fun windowWidth(): Dp {
val direction = LocalLayoutDirection.current
val cutout = WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal).asPaddingValues()
return LocalConfiguration.current.screenWidthDp.dp - cutout.calculateStartPadding(direction) - cutout.calculateEndPadding(direction)
}
@Composable
actual fun windowHeight(): Dp = LocalConfiguration.current.screenHeightDp.dp
@@ -7,10 +7,12 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.chatlist.NavigationBarBackground
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.flow.filter
import kotlin.math.absoluteValue
@@ -25,25 +27,74 @@ actual fun LazyColumnWithScrollBar(
horizontalAlignment: Alignment.Horizontal,
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
fillMaxSize: Boolean,
content: LazyListScope.() -> Unit
) {
val state = state ?: LocalAppBarHandler.current?.listState ?: rememberLazyListState()
val connection = LocalAppBarHandler.current?.connection
val handler = LocalAppBarHandler.current
require(handler != null) { "Using LazyColumnWithScrollBar and without AppBarHandler is an error. Use LazyColumnWithScrollBarNoAppBar instead" }
val state = state ?: handler.listState
val connection = handler.connection
LaunchedEffect(Unit) {
snapshotFlow { state.firstVisibleItemScrollOffset }
.filter { state.firstVisibleItemIndex == 0 }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
if (reverseLayout) {
snapshotFlow { state.layoutInfo.visibleItemsInfo.lastOrNull()?.offset ?: 0 }
.collect { scrollPosition ->
connection.appBarOffset = if (state.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.layoutInfo.totalItemsCount - 1) {
state.layoutInfo.viewportEndOffset - scrollPosition.toFloat() - state.layoutInfo.afterContentPadding
} else {
// show always when last item is not visible
-1000f
}
//Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
} else {
snapshotFlow { state.firstVisibleItemScrollOffset }
.filter { state.firstVisibleItemIndex == 0 }
.collect { scrollPosition ->
val offset = connection.appBarOffset
if ((offset + scrollPosition + state.layoutInfo.afterContentPadding).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
//Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
}
if (connection != null) {
LazyColumn(modifier.nestedScroll(connection), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
} else {
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
LazyColumn(
if (fillMaxSize) {
Modifier.fillMaxSize().copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection)
} else {
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection)
},
state,
contentPadding,
reverseLayout,
verticalArrangement,
horizontalAlignment,
flingBehavior,
userScrollEnabled
) {
content()
}
}
@Composable
actual fun LazyColumnWithScrollBarNoAppBar(
modifier: Modifier,
state: LazyListState?,
contentPadding: PaddingValues,
reverseLayout: Boolean,
verticalArrangement: Arrangement.Vertical,
horizontalAlignment: Alignment.Horizontal,
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
content: LazyListScope.() -> Unit
) {
val state = state ?: rememberLazyListState()
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled) {
content()
}
}
@@ -54,32 +105,80 @@ actual fun ColumnWithScrollBar(
horizontalAlignment: Alignment.Horizontal,
state: ScrollState?,
maxIntrinsicSize: Boolean,
fillMaxSize: Boolean,
content: @Composable() (ColumnScope.() -> Unit)
) {
val state = state ?: LocalAppBarHandler.current?.scrollState ?: rememberScrollState()
val connection = LocalAppBarHandler.current?.connection
val handler = LocalAppBarHandler.current
require(handler != null) { "Using ColumnWithScrollBar and without AppBarHandler is an error. Use ColumnWithScrollBarNoAppBar instead" }
val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier).imePadding() else modifier.imePadding()
val state = state ?: handler.scrollState
val connection = handler.connection
LaunchedEffect(Unit) {
snapshotFlow { state.value }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
val offset = connection.appBarOffset
if ((offset + scrollPosition).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
if (connection != null) {
Column(
if (maxIntrinsicSize) {
modifier.nestedScroll(connection).verticalScroll(state).height(IntrinsicSize.Max)
} else {
modifier.nestedScroll(connection).verticalScroll(state)
}, verticalArrangement, horizontalAlignment, content)
} else {
Column(if (maxIntrinsicSize) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.fillMaxHeight()) {
Column(
if (maxIntrinsicSize) {
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection).verticalScroll(state).height(IntrinsicSize.Max)
} else {
Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).then(modifier).nestedScroll(connection).verticalScroll(state)
}, verticalArrangement, horizontalAlignment
) {
if (oneHandUI.value) {
Spacer(Modifier.padding(top = DEFAULT_PADDING + 5.dp).windowInsetsTopHeight(WindowInsets.statusBars))
content()
Spacer(Modifier.navigationBarsPadding().padding(bottom = AppBarHeight * fontSizeSqrtMultiplier))
} else {
Spacer(Modifier.statusBarsPadding().padding(top = AppBarHeight * fontSizeSqrtMultiplier))
content()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
if (!oneHandUI.value) {
NavigationBarBackground(false, false)
}
}
}
@Composable
actual fun ColumnWithScrollBarNoAppBar(
modifier: Modifier,
verticalArrangement: Arrangement.Vertical,
horizontalAlignment: Alignment.Horizontal,
state: ScrollState?,
maxIntrinsicSize: Boolean,
content: @Composable() (ColumnScope.() -> Unit)
) {
val modifier = modifier.imePadding()
val state = state ?: rememberScrollState()
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.fillMaxHeight()) {
Column(
if (maxIntrinsicSize) {
modifier.verticalScroll(state).height(IntrinsicSize.Max)
} else {
modifier.verticalScroll(state)
}, verticalArrangement, horizontalAlignment, content)
}, verticalArrangement, horizontalAlignment
) {
if (oneHandUI.value) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
content()
} else {
content()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
if (!oneHandUI.value) {
NavigationBarBackground(false, false)
}
}
}
@@ -3,17 +3,18 @@ package chat.simplex.common.platform
import android.app.Activity
import android.content.Context
import android.content.pm.ActivityInfo
import android.graphics.Rect
import android.os.*
import android.view.*
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import chat.simplex.common.AppScreen
import chat.simplex.common.model.clear
import chat.simplex.common.ui.theme.SimpleXTheme
import chat.simplex.common.views.helpers.*
import androidx.compose.ui.platform.LocalContext as LocalContext1
import chat.simplex.res.MR
@@ -43,28 +44,13 @@ actual fun LocalMultiplatformView(): Any? = LocalView.current
@Composable
actual fun getKeyboardState(): State<KeyboardState> {
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
val view = LocalView.current
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
KeyboardState.Opened
} else {
KeyboardState.Closed
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
val density = LocalDensity.current
val ime = WindowInsets.ime
return remember {
derivedStateOf {
if (ime.getBottom(density) == 0) KeyboardState.Closed else KeyboardState.Opened
}
}
return keyboardState
}
actual fun hideKeyboard(view: Any?, clearFocus: Boolean) {
@@ -22,7 +22,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
@@ -48,6 +47,7 @@ import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import com.google.accompanist.permissions.*
import dev.icerock.moko.resources.StringResource
@@ -329,11 +329,14 @@ private fun ActiveCallOverlayLayout(
flipCamera: () -> Unit
) {
Column {
CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
if (call.hasVideo) {
Text(call.contact.chatViewName, Modifier.fillMaxWidth().padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
}
CallAppBar(
title = {
if (call.hasVideo) {
Text(call.contact.chatViewName, Modifier.offset(x = (-4).dp).padding(end = DEFAULT_PADDING), color = Color(0xFFFFFFD8), style = MaterialTheme.typography.h2, overflow = TextOverflow.Ellipsis, maxLines = 1)
}
},
onBack = { chatModel.activeCallViewIsCollapsed.value = true }
)
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
@Composable
fun SelectSoundDevice(size: Dp) {
@@ -411,6 +414,7 @@ private fun ControlButton(icon: Painter, iconText: StringResource, enabled: Bool
@Composable
private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, background: Color = controlButtonsBackground(), size: Dp, content: @Composable () -> Unit) {
val ripple = remember { ripple(bounded = false, radius = size / 2, color = background.lighter(0.1f)) }
Box(
Modifier
.background(background, CircleShape)
@@ -419,7 +423,7 @@ private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, backg
onClick = action,
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = size / 2, color = background.lighter(0.1f)),
indication = ripple,
enabled = enabled
),
contentAlignment = Alignment.Center
@@ -590,8 +594,9 @@ fun CallPermissionsView(pipActive: Boolean, hasVideo: Boolean, cancel: () -> Uni
}
}
} else {
ModalView(background = Color.Black, showClose = false, close = {}) {
ColumnWithScrollBar(Modifier.fillMaxSize()) {
ModalView(background = Color.Black, showAppBar = false, close = {}) {
Column {
Spacer(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier))
AppBarTitle(stringResource(MR.strings.permissions_required))
Spacer(Modifier.weight(1f))
val onClick = {
@@ -8,7 +8,6 @@ import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import chat.simplex.common.model.CIFile
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.helpers.ModalManager
@@ -39,14 +38,6 @@ actual fun SimpleAndAnimatedImageView(
if (getLoadedFilePath(file) != null) {
ModalManager.fullscreen.showCustomModal(animated = false) { close ->
ImageFullScreenView(imageProvider, close)
if (smallView) {
DisposableEffect(Unit) {
onDispose {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
}
}
}
}
}
}
@@ -1,32 +1,26 @@
package chat.simplex.common.views.chatlist
import android.app.Activity
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.common.ANDROID_CALL_TOP_PADDING
import chat.simplex.common.model.durationText
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.datetime.Clock
private val CALL_INTERACTIVE_AREA_HEIGHT = 74.dp
@@ -38,11 +32,12 @@ private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM
@Composable
actual fun ActiveCallInteractiveArea(call: Call) {
val onClick = { platform.androidStartCallActivity(false) }
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT)) {
val statusBar = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(Modifier.offset(y = CALL_TOP_OFFSET).height(CALL_INTERACTIVE_AREA_HEIGHT + statusBar)) {
val source = remember { MutableInteractionSource() }
val indication = rememberRipple(bounded = true, 3000.dp)
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT).clickable(onClick = onClick, indication = indication, interactionSource = source)) {
GreenLine(call)
val ripple = remember { ripple(bounded = true, 3000.dp) }
Box(Modifier.height(CALL_TOP_GREEN_LINE_HEIGHT + statusBar).clickable(onClick = onClick, indication = ripple, interactionSource = source)) {
GreenLine(statusBar, call)
}
Box(
Modifier
@@ -50,7 +45,7 @@ actual fun ActiveCallInteractiveArea(call: Call) {
.size(CALL_BOTTOM_ICON_HEIGHT)
.background(SimplexGreen, CircleShape)
.clip(CircleShape)
.clickable(onClick = onClick, indication = indication, interactionSource = source)
.clickable(onClick = onClick, indication = ripple, interactionSource = source)
.align(Alignment.BottomCenter),
contentAlignment = Alignment.Center
) {
@@ -63,16 +58,13 @@ actual fun ActiveCallInteractiveArea(call: Call) {
}
}
// Temporary solution for storing a color that needs to be applied after call ends
var statusBarColorAfterCall = mutableIntStateOf(CurrentColors.value.colors.background.toArgb())
@Composable
private fun GreenLine(call: Call) {
private fun GreenLine(statusBarHeight: Dp, call: Call) {
Row(
Modifier
.fillMaxSize()
.background(SimplexGreen)
.padding(top = -CALL_TOP_OFFSET)
.padding(top = -CALL_TOP_OFFSET + statusBarHeight)
.padding(horizontal = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
@@ -81,12 +73,10 @@ private fun GreenLine(call: Call) {
Spacer(Modifier.weight(1f))
CallDuration(call)
}
val window = (LocalContext.current as Activity).window
DisposableEffect(Unit) {
statusBarColorAfterCall.intValue = window.statusBarColor
window.statusBarColor = SimplexGreen.toArgb()
platform.androidSetStatusAndNavigationBarAppearance(false, CurrentColors.value.colors.isLight)
onDispose {
window.statusBarColor = statusBarColorAfterCall.intValue
platform.androidSetStatusAndNavigationBarAppearance(CurrentColors.value.colors.isLight, CurrentColors.value.colors.isLight)
}
}
}
@@ -19,13 +19,11 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.User
import chat.simplex.common.model.UserInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -36,6 +34,7 @@ private val USER_PICKER_ROW_PADDING = 16.dp
@Composable
actual fun UserPickerUsersSection(
users: List<UserInfo>,
iconColor: Color,
stopped: Boolean,
onUserClicked: (user: User) -> Unit,
) {
@@ -140,87 +139,73 @@ actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<
} else {
Modifier
}
Box(
Modifier
.fillMaxSize()
.then(clickableModifier)
.drawBehind {
val pos = when {
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
else -> 1 - dismissState.progress.fraction
}
val colors = CurrentColors.value.colors
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
if (pickerState.value.isVisible()) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = colors.background.mixWith(colors.onBackground, 1 - userPickerAlpha())
)
} else if (ModalManager.start.modalCount.value == 0) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
} else {
colors.background
})
)
}
drawRect(
if (pos != 0f) resultingColor else Color.Transparent,
alpha = calculateFraction(pos = pos)
)
}
.graphicsLayer {
if (heightValue == 0) {
alpha = 0f
}
translationY = dismissState.offset.value
},
contentAlignment = Alignment.BottomCenter
) {
Box {
Box(
Modifier.onSizeChanged { height.intValue = it.height }
) {
KeyChangeEffect(pickerIsVisible) {
if (pickerState.value.isVisible()) {
try {
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.GONE
Modifier
.fillMaxSize()
.then(clickableModifier)
.drawBehind {
val pos = calculatePosition(dismissState)
val colors = CurrentColors.value.colors
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
drawRect(
if (pos != 0f) resultingColor else Color.Transparent,
alpha = calculateFraction(pos = pos)
)
}
.graphicsLayer {
if (heightValue == 0) {
alpha = 0f
}
} else {
try {
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.VISIBLE
translationY = dismissState.offset.value
},
contentAlignment = Alignment.BottomCenter
) {
Box(
Modifier.onSizeChanged { height.intValue = it.height }
) {
KeyChangeEffect(pickerIsVisible) {
if (pickerState.value.isVisible()) {
try {
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.GONE
}
} else {
try {
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.VISIBLE
}
}
}
}
val draggableModifier = if (height.intValue != 0)
Modifier.draggableBottomDrawerModifier(
state = dismissState,
swipeDistance = height.intValue.toFloat(),
)
else Modifier
Box(draggableModifier.then(modifier)) {
content()
val draggableModifier = if (height.intValue != 0)
Modifier.draggableBottomDrawerModifier(
state = dismissState,
swipeDistance = height.intValue.toFloat(),
)
else Modifier
Box(draggableModifier.then(modifier).navigationBarsPadding()) {
content()
}
}
}
NavigationBarBackground(
modifier = Modifier.graphicsLayer { alpha = if (calculatePosition(dismissState) > 0.1f) 1f else 0f },
color = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha())
)
}
}
private fun calculatePosition(dismissState: DismissState): Float = when {
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
else -> 1 - dismissState.progress.fraction
}
private fun Modifier.draggableBottomDrawerModifier(
state: DismissState,
swipeDistance: Float,
@@ -171,6 +171,8 @@ actual fun GetImageBottomSheet(
modifier = Modifier
.fillMaxWidth()
.wrapContentHeight()
.imePadding()
.navigationBarsPadding()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hideBottomSheet()
}
@@ -0,0 +1,41 @@
package chat.simplex.common.views.helpers
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.FrameLayout
/**
* A workaround for the ANR issue on Compose 1.7.x.
* https://issuetracker.google.com/issues/369354336
* Code from:
* https://issuetracker.google.com/issues/369354336#comment8
*/
class WorkaroundFocusSearchLayout : FrameLayout {
constructor(
context: Context,
) : super(context)
constructor(
context: Context,
attrs: AttributeSet?,
) : super(context, attrs)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
) : super(context, attrs, defStyleAttr)
constructor(
context: Context,
attrs: AttributeSet?,
defStyleAttr: Int,
defStyleRes: Int,
) : super(context, attrs, defStyleAttr, defStyleRes)
override fun focusSearch(focused: View?, direction: Int): View? {
return null
}
}
@@ -2,6 +2,7 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionSpacer
import SectionView
import android.app.Activity
import android.content.ComponentName
@@ -31,6 +32,7 @@ import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.saveAppLocale
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.compose.painterResource
@@ -75,9 +77,7 @@ fun AppearanceScope.AppearanceLayout(
systemDarkTheme: SharedPreference<String?>,
changeIcon: (AppIcon) -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.appearance_settings))
SectionView(stringResource(MR.strings.settings_section_title_interface), contentPadding = PaddingValues()) {
val context = LocalContext.current
@@ -106,15 +106,15 @@ fun AppearanceScope.AppearanceLayout(
}
// }
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI) {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, false, false)
}
SettingsPreferenceItem(icon = null, stringResource(MR.strings.one_hand_ui), ChatModel.controller.appPrefs.oneHandUI)
}
SectionDividerSpaced()
ThemesSection(systemDarkTheme)
SectionDividerSpaced()
AppToolbarsSection()
SectionDividerSpaced()
MessageShapeSection()
@@ -13,14 +13,13 @@ import dev.icerock.moko.resources.compose.stringResource
@Composable
actual fun SettingsSectionApp(
showSettingsModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit
) {
SectionView(stringResource(MR.strings.settings_section_title_app)) {
SettingsActionItem(painterResource(MR.images.ic_restart_alt), stringResource(MR.strings.settings_restart_app), ::restartApp)
SettingsActionItem(painterResource(MR.images.ic_power_settings_new), stringResource(MR.strings.settings_shutdown), { shutdownAppAlert(::shutdownApp) })
SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(it, showCustomModal, withAuth) })
SettingsActionItem(painterResource(MR.images.ic_code), stringResource(MR.strings.settings_developer_tools), showSettingsModal { DeveloperView(withAuth) })
AppVersionItem(showVersion)
}
}
@@ -11,10 +11,13 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
import chat.simplex.common.model.*
@@ -39,14 +42,39 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.absoluteValue
@Composable
fun AppScreen() {
AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
SimpleXTheme {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
MainScreen()
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
// This padding applies to landscape view only taking care of navigation bar and holes in screen in status bar area
// (because nav bar and holes located on vertical sides of screen in landscape view)
val direction = LocalLayoutDirection.current
val safePadding = WindowInsets.safeDrawing.asPaddingValues()
val cutout = WindowInsets.displayCutout.asPaddingValues()
val cutoutStart = cutout.calculateStartPadding(direction)
val cutoutEnd = cutout.calculateEndPadding(direction)
val cutoutMax = maxOf(cutoutStart, cutoutEnd)
val paddingStartUntouched = safePadding.calculateStartPadding(direction)
val paddingStart = paddingStartUntouched - cutoutStart
val paddingEndUntouched = safePadding.calculateEndPadding(direction)
val paddingEnd = paddingEndUntouched - cutoutEnd
// Such a strange layout is needed because the main content should be covered by solid color in order to hide overflow
// of some elements that may have negative offset (so, can't use Row {}).
// To check: go to developer settings of Android, choose Display cutout -> Punch hole, and rotate the phone to landscape, open any chat
Box {
val fullscreenGallery = remember { chatModel.fullscreenGalleryVisible }
Box(Modifier.padding(start = paddingStart + cutoutMax, end = paddingEnd + cutoutMax).consumeWindowInsets(PaddingValues(start = paddingStartUntouched, end = paddingEndUntouched))) {
Box(Modifier.drawBehind {
if (fullscreenGallery.value) {
drawRect(Color.Black, topLeft = Offset(-(paddingStart + cutoutMax).toPx(), 0f), Size(size.width + (paddingStart + cutoutMax).toPx() + (paddingEnd + cutoutMax).toPx(), size.height))
}
}) {
MainScreen()
}
}
}
}
}
@@ -138,7 +166,9 @@ fun MainScreen() {
}
SetupClipboardListener()
if (appPlatform.isAndroid) {
AndroidScreen(userPickerState)
AndroidWrapInCallLayout {
AndroidScreen(userPickerState)
}
} else {
DesktopScreen(userPickerState)
}
@@ -170,7 +200,9 @@ fun MainScreen() {
}
}
if (appPlatform.isAndroid) {
ModalManager.fullscreen.showInView()
AndroidWrapInCallLayout {
ModalManager.fullscreen.showInView()
}
SwitchingUsersView()
}
@@ -237,19 +269,39 @@ fun MainScreen() {
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable
fun AndroidWrapInCallLayout(content: @Composable () -> Unit) {
val call = remember { chatModel.activeCall}.value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
Box {
Box(Modifier.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)) {
content()
}
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call)
}
}
}
@Composable
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
val currentChatId = remember { mutableStateOf(chatModel.chatId.value) }
val offset = remember { Animatable(if (chatModel.chatId.value == null) 0f else maxWidth.value) }
val cutout = WindowInsets.displayCutout.only(WindowInsetsSides.Horizontal).asPaddingValues()
val direction = LocalLayoutDirection.current
val hasCutout = cutout.calculateStartPadding(direction) + cutout.calculateEndPadding(direction) > 0.dp
Box(
Modifier
// clipping only for devices with cutout currently visible on sides. It prevents showing chat list with open chat view
// In order cases it's not needed to use clip
.then(if (hasCutout) Modifier.clip(RectangleShape) else Modifier)
.graphicsLayer {
translationX = -offset.value.dp.toPx()
// minOf thing is needed for devices with holes in screen while the user on ChatView rotates his phone from portrait to landscape
// because in this case (at least in emulator) maxWidth changes in two steps: big first, smaller on next frame.
// But offset is remembered already, so this is a better way than dropping a value of offset
translationX = -minOf(offset.value.dp, maxWidth).toPx()
}
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) {
StartPartOfScreen(userPickerState)
}
@@ -271,51 +323,40 @@ fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.collect {
if (it == null) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
onComposed(null)
}
if (it == null) onComposed(null)
currentChatId.value = it
}
}
}
LaunchedEffect(Unit) {
snapshotFlow { ModalManager.center.modalCount.value > 0 }
.filter { chatModel.chatId.value == null }
.collect { modalBackground ->
if (chatModel.newChatSheetVisible.value) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, appPrefs.oneHandUI.get())
} else if (modalBackground) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, false)
} else {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
}
}
}
Box(Modifier
.graphicsLayer { translationX = maxWidth.toPx() - offset.value.dp.toPx() }
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
.then(if (hasCutout) Modifier.clip(RectangleShape) else Modifier)
.graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() }
) Box2@{
currentChatId.value?.let {
ChatView(currentChatId, onComposed)
}
}
if (call != null && showCallArea) {
ActiveCallInteractiveArea(call)
}
}
}
@Composable
fun StartPartOfScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel)
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
SetDeliveryReceiptsView(chatModel)
}
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, stopped)
if (chatModel.sharedContent.value == null) {
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
}
} else {
// LALAL initial load of view doesn't show blur. Focusing text field shows it
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(keyboardCoversBar = false)) {
ShareListView(chatModel, stopped)
}
}
}
}
@@ -90,6 +90,9 @@ object ChatModel {
// Needed to check for bottom nav bar and to apply or not navigation bar color on Android
val newChatSheetVisible = mutableStateOf(false)
// Needed to apply black color to left/right cutout area on Android
val fullscreenGalleryVisible = mutableStateOf(false)
// preferences
val notificationPreviewMode by lazy {
mutableStateOf(
@@ -118,6 +118,9 @@ class AppPreferences {
val privacyEncryptLocalFiles = mkBoolPreference(SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES, true)
val privacyAskToApproveRelays = mkBoolPreference(SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS, true)
val privacyMediaBlurRadius = mkIntPreference(SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS, 0)
// Blur broken on Android 12, see https://github.com/chrisbanes/haze/issues/77. And not available before 12
val deviceSupportsBlur = appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 32
val appearanceBarsBlurRadius = mkIntPreference(SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS, if (deviceSupportsBlur) 50 else 0)
val experimentalCalls = mkBoolPreference(SHARED_PREFS_EXPERIMENTAL_CALLS, false)
val showUnreadAndFavorites = mkBoolPreference(SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES, false)
val chatArchiveName = mkStrPreference(SHARED_PREFS_CHAT_ARCHIVE_NAME, null)
@@ -223,6 +226,8 @@ class AppPreferences {
val chatItemTail = mkBoolPreference(SHARED_PREFS_CHAT_ITEM_TAIL, true)
val fontScale = mkFloatPreference(SHARED_PREFS_FONT_SCALE, 1f)
val densityScale = mkFloatPreference(SHARED_PREFS_DENSITY_SCALE, 1f)
val inAppBarsDefaultAlpha = if (deviceSupportsBlur) 0.875f else 0.975f
val inAppBarsAlpha = mkFloatPreference(SHARED_PREFS_IN_APP_BARS_ALPHA, inAppBarsDefaultAlpha)
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
@@ -244,7 +249,7 @@ class AppPreferences {
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, appPlatform.isAndroid)
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true)
val hintPreferences: List<Pair<SharedPreference<Boolean>, Boolean>> = listOf(
laNoticeShown to false,
@@ -362,6 +367,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_ENCRYPT_LOCAL_FILES = "PrivacyEncryptLocalFiles"
private const val SHARED_PREFS_PRIVACY_ASK_TO_APPROVE_RELAYS = "PrivacyAskToApproveRelays"
private const val SHARED_PREFS_PRIVACY_MEDIA_BLUR_RADIUS = "PrivacyMediaBlurRadius"
private const val SHARED_PREFS_APPEARANCE_BARS_BLUR_RADIUS = "AppearanceBarsBlurRadius"
const val SHARED_PREFS_PRIVACY_FULL_BACKUP = "FullBackup"
private const val SHARED_PREFS_EXPERIMENTAL_CALLS = "ExperimentalCalls"
private const val SHARED_PREFS_SHOW_UNREAD_AND_FAVORITES = "ShowUnreadAndFavorites"
@@ -428,6 +434,7 @@ class AppPreferences {
private const val SHARED_PREFS_CHAT_ITEM_TAIL = "ChatItemTail"
private const val SHARED_PREFS_FONT_SCALE = "FontScale"
private const val SHARED_PREFS_DENSITY_SCALE = "DensityScale"
private const val SHARED_PREFS_IN_APP_BARS_ALPHA = "InAppBarsAlpha"
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode"
private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime"
@@ -1226,6 +1233,15 @@ object ChatController {
)
return null
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.SMP
&& r.chatError.agentError.smpErr is SMPErrorType.QUOTA -> {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.connection_error_quota),
generalGetString(MR.strings.connection_error_quota_desc)
)
return null
}
else -> {
if (!(networkErrorAlert(r))) {
apiErrorAlert("apiConnect", generalGetString(MR.strings.connection_error), r)
@@ -6045,6 +6061,7 @@ sealed class SMPErrorType {
is AUTH -> "AUTH"
is CRYPTO -> "CRYPTO"
is QUOTA -> "QUOTA"
is STORE -> "STORE ${storeErr}"
is NO_MSG -> "NO_MSG"
is LARGE_MSG -> "LARGE_MSG"
is EXPIRED -> "EXPIRED"
@@ -6057,6 +6074,7 @@ sealed class SMPErrorType {
@Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
@Serializable @SerialName("CRYPTO") class CRYPTO: SMPErrorType()
@Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
@Serializable @SerialName("STORE") class STORE(val storeErr: String): SMPErrorType()
@Serializable @SerialName("NO_MSG") class NO_MSG: SMPErrorType()
@Serializable @SerialName("LARGE_MSG") class LARGE_MSG: SMPErrorType()
@Serializable @SerialName("EXPIRED") class EXPIRED: SMPErrorType()
@@ -14,20 +14,11 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import java.io.File
expect fun Modifier.navigationBarsWithImePadding(): Modifier
@Composable
expect fun ProvideWindowInsets(
consumeWindowInsets: Boolean = true,
windowInsetsAnimationsEnabled: Boolean = true,
content: @Composable () -> Unit
)
@Composable
expect fun Modifier.desktopOnExternalDrag(
enabled: Boolean = true,
onFiles: (List<File>) -> Unit = {},
onImage: (Painter) -> Unit = {},
onImage: (File) -> Unit = {},
onText: (String) -> Unit = {}
): Modifier
@@ -8,6 +8,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatId
import chat.simplex.common.model.NotificationsMode
import chat.simplex.common.ui.theme.CurrentColors
import kotlinx.coroutines.Job
interface PlatformInterface {
@@ -20,12 +21,12 @@ interface PlatformInterface {
fun androidChatInitializedAndStarted() {}
fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
fun androidSetStatusAndNavigationBarAppearance(isLightStatusBar: Boolean, isLightNavBar: Boolean, blackNavBar: Boolean = false, themeBackgroundColor: Color = CurrentColors.value.colors.background) {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
fun androidRestartNetworkObserver() {}
val androidApiLevel: Int? get() = null
@Composable fun androidLockPortraitOrientation() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
@Composable fun desktopShowAppUpdateNotice() {}
@@ -8,6 +8,7 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@Composable
@@ -21,11 +22,44 @@ expect fun LazyColumnWithScrollBar(
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
fillMaxSize: Boolean = true,
content: LazyListScope.() -> Unit
)
@Composable
expect fun LazyColumnWithScrollBarNoAppBar(
modifier: Modifier = Modifier,
state: LazyListState? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
if (!reverseLayout) Arrangement.Top else Arrangement.Bottom,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
content: LazyListScope.() -> Unit
)
@Composable
expect fun ColumnWithScrollBar(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
state: ScrollState? = null,
// set true when you want to show something in the center with respected .fillMaxSize()
maxIntrinsicSize: Boolean = false,
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
fillMaxSize: Boolean = true,
content: @Composable ColumnScope.() -> Unit
)
@Composable
expect fun ColumnWithScrollBarNoAppBar(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
@@ -1,14 +1,14 @@
package chat.simplex.common.ui.theme
import androidx.compose.foundation.background
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
@@ -587,21 +587,27 @@ data class ThemeModeOverride (
}
}
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, shape: Shape = RectangleShape): Modifier {
return if (baseTheme == DefaultTheme.SIMPLEX) {
this.background(brush = Brush.linearGradient(
listOf(
CurrentColors.value.colors.background.darker(0.4f),
CurrentColors.value.colors.background.lighter(0.4f)
),
Offset(0f, Float.POSITIVE_INFINITY),
Offset(Float.POSITIVE_INFINITY, 0f)
), shape = shape)
} else {
this.background(color = CurrentColors.value.colors.background, shape = shape)
fun Modifier.themedBackground(baseTheme: DefaultTheme = CurrentColors.value.base, bgLayerSize: MutableState<IntSize>?, bgLayer: GraphicsLayer?/*, shape: Shape = RectangleShape*/): Modifier {
return drawBehind {
copyBackgroundToAppBar(bgLayerSize, bgLayer) {
if (baseTheme == DefaultTheme.SIMPLEX) {
drawRect(brush = themedBackgroundBrush())
} else {
drawRect(CurrentColors.value.colors.background)
}
}
}
}
fun themedBackgroundBrush(): Brush = Brush.linearGradient(
listOf(
CurrentColors.value.colors.background.darker(0.4f),
CurrentColors.value.colors.background.lighter(0.4f)
),
Offset(0f, Float.POSITIVE_INFINITY),
Offset(Float.POSITIVE_INFINITY, 0f)
)
val DEFAULT_PADDING = 20.dp
val DEFAULT_SPACE_AFTER_ICON = 4.dp
val DEFAULT_PADDING_HALF = DEFAULT_PADDING / 2
@@ -1,7 +1,6 @@
package chat.simplex.common.ui.theme
import androidx.compose.material.Colors
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.MutableState
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
@@ -107,7 +106,7 @@ object ThemeManager {
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
platform.androidSetNightModeIfSupported()
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !ChatController.appPrefs.oneHandUI.get(), ChatController.appPrefs.oneHandUI.get())
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
}
fun changeDarkTheme(theme: String) {
@@ -125,10 +124,6 @@ object ThemeManager {
themeIds[nonSystemThemeName] = prevValue.themeId
appPrefs.currentThemeIds.set(themeIds)
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
if (name == ThemeColor.BACKGROUND) {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, false, false)
}
}
fun applyThemeColor(name: ThemeColor, color: Color? = null, pref: MutableState<ThemeModeOverride>) {
@@ -7,40 +7,34 @@ import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import kotlinx.coroutines.flow.collect
import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID
import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout
import chat.simplex.common.views.chatlist.NavigationBarBackground
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@Composable
fun TerminalView(floating: Boolean = false, close: () -> Unit) {
fun TerminalView(floating: Boolean = false) {
val composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) }
val close = {
close()
if (appPlatform.isDesktop) {
ModalManager.center.closeModals()
}
}
BackHandler(onBack = {
close()
})
TerminalLayout(
composeState,
floating,
sendCommand = { sendCommand(chatModel, composeState) },
close
)
}
@@ -69,7 +63,6 @@ fun TerminalLayout(
composeState: MutableState<ComposeState>,
floating: Boolean,
sendCommand: () -> Unit,
close: () -> Unit
) {
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
val textStyle = remember { mutableStateOf(smallFont) }
@@ -77,65 +70,63 @@ fun TerminalLayout(
fun onMessageChange(s: String) {
composeState.value = composeState.value.copy(message = s)
}
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
bottomBar = {
Column {
Divider()
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
placeholder = "",
sendMessage = { sendCommand() },
sendLiveMessage = null,
updateLiveMessage = null,
editPrevMessage = {},
onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle
)
}
}
},
contentColor = LocalContentColor.current,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth(),
color = MaterialTheme.colors.background,
contentColor = LocalContentColor.current
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(Modifier.fillMaxSize()) {
val composeViewHeight = remember { mutableStateOf(0.dp) }
AdaptingBottomPaddingLayout(Modifier, CONSOLE_COMPOSE_LAYOUT_ID, composeViewHeight) {
TerminalLog(floating, composeViewHeight)
Column(
Modifier
.layoutId(CONSOLE_COMPOSE_LAYOUT_ID)
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp))
.imePadding()
.padding(bottom = if (oneHandUI.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp)
.background(MaterialTheme.colors.background)
) {
TerminalLog(floating)
Divider()
Box(Modifier.padding(horizontal = 8.dp)) {
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
recState = remember { mutableStateOf(RecordingState.NotStarted) },
isDirectChat = false,
liveMessageAlertShown = SharedPreference(get = { false }, set = {}),
sendMsgEnabled = true,
sendButtonEnabled = true,
nextSendGrpInv = false,
needToAllowVoiceToContact = false,
allowedVoiceByPrefs = false,
userIsObserver = false,
userCanSend = true,
allowVoiceToContact = {},
placeholder = "",
sendMessage = { sendCommand() },
sendLiveMessage = null,
updateLiveMessage = null,
editPrevMessage = {},
onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle
)
}
}
}
if (!oneHandUI.value) {
NavigationBarBackground(true, oneHandUI.value)
}
}
}
@Composable
fun TerminalLog(floating: Boolean) {
fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
val reversedTerminalItems by remember {
derivedStateOf { chatModel.terminalItems.value.asReversed() }
}
val clipboard = LocalClipboardManager.current
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
LaunchedEffect(Unit) {
var autoScrollToBottom = true
var autoScrollToBottom = listState.firstVisibleItemIndex <= 1
launch {
snapshotFlow { listState.layoutInfo.totalItemsCount }
.filter { autoScrollToBottom }
@@ -150,12 +141,21 @@ fun TerminalLog(floating: Boolean) {
launch {
snapshotFlow { listState.firstVisibleItemIndex }
.collect {
autoScrollToBottom = listState.firstVisibleItemIndex == 0
autoScrollToBottom = it == 0
}
}
}
LazyColumnWithScrollBar(reverseLayout = true, state = listState) {
LazyColumnWithScrollBar (
reverseLayout = true,
contentPadding = PaddingValues(
top = topPaddingToContent(),
bottom = composeViewHeight.value
),
state = listState,
additionalBarOffset = composeViewHeight
) {
items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item ->
val clipboard = LocalClipboardManager.current
val rhId = item.remoteHostId
val rhIdStr = if (rhId == null) "" else "$rhId "
Text(
@@ -172,13 +172,15 @@ fun TerminalLog(floating: Boolean) {
ModalManager.start
}
modalPlace.showModal(endButtons = { ShareButton { clipboard.shareText(item.details) } }) {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
val details = item.details
.let {
if (it.length < 100_000) it
else it.substring(0, 100_000)
}
Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
ColumnWithScrollBar {
SelectionContainer {
val details = item.details
.let {
if (it.length < 100_000) it
else it.substring(0, 100_000)
}
Text(details, modifier = Modifier.heightIn(max = 50_000.dp).padding(horizontal = DEFAULT_PADDING).padding(bottom = DEFAULT_PADDING))
}
}
}
}.padding(horizontal = 8.dp, vertical = 4.dp)
@@ -208,8 +210,7 @@ fun PreviewTerminalLayout() {
TerminalLayout(
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = false)) },
sendCommand = {},
floating = false,
close = {}
floating = false
)
}
}
@@ -40,8 +40,6 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -50,11 +48,9 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
val displayName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
ColumnWithScrollBar(
modifier = Modifier.fillMaxSize()
) {
ColumnWithScrollBar {
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
AppBarTitle(stringResource(MR.strings.create_profile), withPadding = false, bottomPadding = DEFAULT_PADDING)
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
stringResource(MR.strings.display_name),
@@ -102,7 +98,6 @@ fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
}
}
}
}
}
@Composable
@@ -111,59 +106,42 @@ fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Column(
modifier = Modifier
.fillMaxSize()
.themedBackground(),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close = {
if (chatModel.users.none { !it.user.hidden }) {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else {
close()
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({
if (chatModel.users.none { !it.user.hidden }) {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
} else {
close()
}
}) {
ColumnWithScrollBar {
val displayName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Column(if (appPlatform.isAndroid) Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING) else Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING, withPadding = false)
}
})
BackHandler(onBack = {
appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo)
})
ProfileNameField(displayName, stringResource(MR.strings.display_name), { it.trim() == mkValidName(it) }, focusRequester)
Spacer(Modifier.height(DEFAULT_PADDING))
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Start, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.create_profile_button,
onboarding = null,
enabled = canCreateProfile(displayName.value),
onclick = { createProfileOnboarding(chat.simplex.common.platform.chatModel, displayName.value, close) }
)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
ColumnWithScrollBar(
modifier = Modifier.fillMaxSize()
) {
val displayName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Column(if (appPlatform.isAndroid) Modifier.fillMaxSize().padding(horizontal = DEFAULT_PADDING) else Modifier.widthIn(max = 600.dp).fillMaxHeight().padding(horizontal = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING, withPadding = false)
}
ProfileNameField(displayName, stringResource(MR.strings.display_name), { it.trim() == mkValidName(it) }, focusRequester)
Spacer(Modifier.height(DEFAULT_PADDING))
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Start, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Start, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
}
Spacer(Modifier.fillMaxHeight().weight(1f))
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingActionButton(
if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_PADDING * 2).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.create_profile_button,
onboarding = null,
enabled = canCreateProfile(displayName.value),
onclick = { createProfileOnboarding(chat.simplex.common.platform.chatModel, displayName.value, close) }
)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
LaunchedEffect(Unit) {
@@ -255,7 +233,6 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
val modifier = Modifier
.fillMaxWidth()
.heightIn(min = 50.dp)
.navigationBarsWithImePadding()
.onFocusChanged { focused = it.isFocused }
Column(
Modifier
@@ -289,6 +266,7 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
enabled = true,
isError = false,
interactionSource = remember { MutableInteractionSource() },
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
)
}
)
@@ -53,7 +53,7 @@ fun IncomingCallAlertLayout(
acceptCall: () -> Unit
) {
val color = if (isInDarkTheme()) MaterialTheme.colors.surface else IncomingCallLight
Column(Modifier.fillMaxWidth().background(color).padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
Column(Modifier.fillMaxWidth().background(color).statusBarsPadding().padding(top = DEFAULT_PADDING, bottom = DEFAULT_PADDING, start = DEFAULT_PADDING, end = 8.dp)) {
IncomingCallInfo(invitation, chatModel)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween) {
@@ -529,10 +529,7 @@ fun ChatInfoLayout(
KeyChangeEffect(chat.id) {
scope.launch { scrollState.scrollTo(0) }
}
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
) {
ColumnWithScrollBar {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
@@ -276,7 +276,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun HistoryTab() {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val versions = ciInfo.itemVersions
@@ -300,7 +300,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun QuoteTab(qi: CIQuote) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
SectionView(contentPadding = PaddingValues(horizontal = DEFAULT_PADDING)) {
@@ -313,7 +313,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun ForwardedFromTab(forwardedFromItem: AChatItem) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
SectionView {
@@ -375,7 +375,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun DeliveryTab(memberDeliveryStatuses: List<MemberDeliveryStatus>) {
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
ColumnWithScrollBar {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
val mss = membersStatuses(chatModel, memberDeliveryStatuses)
@@ -12,10 +12,11 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.layout.layoutId
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -108,6 +109,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
}
val clipboard = LocalClipboardManager.current
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false)) {
when (chatInfo) {
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
@@ -523,28 +525,10 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
showViaProxy = chatModel.controller.appPrefs.showSentViaProxy.get(),
showSearch = showSearch
)
if (appPlatform.isAndroid) {
val backgroundColor = MaterialTheme.colors.background
val backgroundColorState = rememberUpdatedState(backgroundColor)
LaunchedEffect(Unit) {
snapshotFlow { ModalManager.center.modalCount.value > 0 }
.collect { modalBackground ->
if (modalBackground) {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, CurrentColors.value.colors.background, false, false)
} else {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, backgroundColorState.value, true, false)
}
}
}
}
}
}
is ChatInfo.ContactConnection -> {
val close = { chatModel.chatId.value = null }
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(close, showClose = appPlatform.isAndroid, content = {
ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, false, close)
})
@@ -553,14 +537,9 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
ModalManager.end.closeModals()
chatModel.chatItems.clear()
}
}
}
is ChatInfo.InvalidJSON -> {
val close = { chatModel.chatId.value = null }
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(close, showClose = appPlatform.isAndroid, endButtons = { ShareButton { clipboard.shareText(chatInfo.json) } }, content = {
InvalidJSONView(chatInfo.json)
})
@@ -569,10 +548,10 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
ModalManager.end.closeModals()
chatModel.chatItems.clear()
}
}
}
else -> {}
}
}
}
}
@@ -642,81 +621,67 @@ fun ChatLayout(
.desktopOnExternalDrag(
enabled = remember(attachmentDisabled.value, chatInfo.value?.userCanSend) { mutableStateOf(!attachmentDisabled.value && chatInfo.value?.userCanSend == true) }.value,
onFiles = { paths -> composeState.onFilesAttached(paths.map { it.toURI() }) },
onImage = {
// TODO: file is not saved anywhere?!
val tmpFile = File.createTempFile("image", ".bmp", tmpDir)
tmpFile.deleteOnExit()
chatModel.filesToDelete.add(tmpFile)
val uri = tmpFile.toURI()
CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(uri), null) }
},
onImage = { file -> CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(listOf(file.toURI()), null) } },
onText = {
// Need to parse HTML in order to correctly display the content
//composeState.value = composeState.value.copy(message = composeState.value.message + it)
},
)
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
sheetElevation = 0.dp,
sheetContent = {
ChooseAttachmentView(
attachmentOption,
hide = { scope.launch { attachmentBottomSheetState.hide() } }
)
},
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val floatingButton: MutableState<@Composable () -> Unit> = remember { mutableStateOf({}) }
val setFloatingButton = { button: @Composable () -> Unit ->
floatingButton.value = button
}
Scaffold(
topBar = {
if (selectedChatItems.value == null) {
val chatInfo = chatInfo.value
if (chatInfo != null) {
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
}
} else {
SelectedItemsTopToolbar(selectedChatItems)
}
},
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
contentColor = LocalContentColor.current,
backgroundColor = Color.Unspecified
) { contentPadding ->
val wallpaperImage = MaterialTheme.wallpaper.type.image
val wallpaperType = MaterialTheme.wallpaper.type
val backgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, MaterialTheme.colors.background)
val tintColor = MaterialTheme.wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
BoxWithConstraints(Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.then(if (wallpaperImage != null)
Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor) }
else
Modifier)
.padding(contentPadding)
) {
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
if (chatInfo != null) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
sheetElevation = 0.dp,
sheetContent = {
ChooseAttachmentView(
attachmentOption,
hide = { scope.launch { attachmentBottomSheetState.hide() } }
)
},
sheetState = attachmentBottomSheetState,
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
val composeViewHeight = remember { mutableStateOf(0.dp) }
Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
val remoteHostId = remember { remoteHostId }.value
val chatInfo = remember { chatInfo }.value
AdaptingBottomPaddingLayout(Modifier, CHAT_COMPOSE_LAYOUT_ID, composeViewHeight) {
if (chatInfo != null) {
Box(Modifier.fillMaxSize()) {
ChatItemsList(
remoteHostId, chatInfo, unreadCount, composeState, searchValue,
remoteHostId, chatInfo, unreadCount, composeState, composeViewHeight, searchValue,
useLinkPreviews, linkMode, selectedChatItems, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, forwardItem,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools, showViaProxy,
setReaction, showItemDetails, markRead, remember { { onComposed(it) } }, developerTools, showViaProxy,
)
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
Box(
Modifier
.layoutId(CHAT_COMPOSE_LAYOUT_ID)
.align(Alignment.BottomCenter)
.imePadding()
.navigationBarsPadding()
.then(if (oneHandUI.value) Modifier.padding(bottom = AppBarHeight * fontSizeSqrtMultiplier) else Modifier)
) {
composeView()
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
if (oneHandUI.value) {
StatusBarBackground()
} else {
NavigationBarBackground(true, oneHandUI.value, noAlpha = true)
}
Box(if (oneHandUI.value) Modifier.align(Alignment.BottomStart).imePadding() else Modifier) {
if (selectedChatItems.value == null) {
if (chatInfo != null) {
ChatInfoToolbar(chatInfo, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged, showSearch)
}
} else {
SelectedItemsTopToolbar(selectedChatItems)
}
}
}
}
@@ -724,7 +689,7 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(
fun BoxScope.ChatInfoToolbar(
chatInfo: ChatInfo,
back: () -> Unit,
info: () -> Unit,
@@ -876,21 +841,33 @@ fun ChatInfoToolbar(
}
}
}
DefaultTopAppBar(
val oneHandUI = remember { appPrefs.oneHandUI.state }
DefaultAppBar(
navigationButton = { if (appPlatform.isAndroid || showSearch.value) { NavigationButtonBack(onBackClicked) } },
title = { ChatInfoToolbarTitle(chatInfo) },
onTitleClick = if (chatInfo is ChatInfo.Local) null else info,
showSearch = showSearch.value,
onTop = !oneHandUI.value,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
buttons = { barButtons.forEach { it() } }
)
Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier))
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight * fontSizeSqrtMultiplier)) {
DefaultDropdownMenu(showMenu) {
menuItems.forEach { it() }
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
val density = LocalDensity.current
val width = remember { mutableStateOf(250.dp) }
val height = remember { mutableStateOf(0.dp) }
DefaultDropdownMenu(
showMenu,
modifier = Modifier.onSizeChanged { with(density) {
width.value = it.width.toDp().coerceAtLeast(250.dp)
if (oneHandUI.value && (appPlatform.isDesktop || (platform.androidApiLevel ?: 0) >= 30)) height.value = it.height.toDp()
} },
offset = DpOffset(-width.value, if (oneHandUI.value) -height.value else AppBarHeight)
) {
if (oneHandUI.value) {
menuItems.asReversed().forEach { it() }
} else {
menuItems.forEach { it() }
}
}
}
}
@@ -934,11 +911,12 @@ private fun ContactVerifiedShield() {
}
@Composable
fun BoxWithConstraintsScope.ChatItemsList(
fun BoxScope.ChatItemsList(
remoteHostId: Long?,
chatInfo: ChatInfo,
unreadCount: State<Int>,
composeState: MutableState<ComposeState>,
composeViewHeight: State<Dp>,
searchValue: State<String>,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
@@ -963,7 +941,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
showViaProxy: Boolean
@@ -987,13 +964,18 @@ fun BoxWithConstraintsScope.ChatItemsList(
PreloadItems(chatInfo.id, listState, ChatPagination.UNTIL_PRELOAD_COUNT, loadPrevMessages)
Spacer(Modifier.size(8.dp))
val reversedChatItems by remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val maxHeightRounded = with(LocalDensity.current) { maxHeight.roundToPx() }
val scrollToItem: (Long) -> Unit = { itemId: Long ->
val index = reversedChatItems.indexOfFirst { it.id == itemId }
if (index != -1) {
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.lastIndex, index + 1), -maxHeightRounded) }
}
val reversedChatItems = remember { derivedStateOf { chatModel.chatItems.asReversed() } }
val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent().roundToPx() })
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportEndOffset - topPaddingToContentPx.value } }
val scrollToItem: State<(Long) -> Unit> = remember {
mutableStateOf(
{ itemId: Long ->
val index = reversedChatItems.value.indexOfFirst { it.id == itemId }
if (index != -1) {
scope.launch { listState.animateScrollToItem(kotlin.math.min(reversedChatItems.value.lastIndex, index + 1), -maxHeight.value) }
}
}
)
}
// TODO: Having this block on desktop makes ChatItemsList() to recompose twice on chatModel.chatId update instead of once
LaunchedEffect(chatInfo.id) {
@@ -1011,8 +993,18 @@ fun BoxWithConstraintsScope.ChatItemsList(
VideoPlayerHolder.releaseAll()
}
)
LazyColumnWithScrollBar(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) {
itemsIndexed(reversedChatItems, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
LazyColumnWithScrollBar(
Modifier.align(Alignment.BottomCenter),
state = listState,
reverseLayout = true,
contentPadding = PaddingValues(
top = topPaddingToContent(),
bottom = composeViewHeight.value
),
additionalBarOffset = composeViewHeight
) {
itemsIndexed(reversedChatItems.value, key = { _, item -> item.id to item.meta.createdAt.toEpochMilliseconds() }) { i, cItem ->
val itemScope = rememberCoroutineScope()
CompositionLocalProvider(
// Makes horizontal and vertical scrolling to coexist nicely.
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
@@ -1020,10 +1012,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
val provider = {
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
scope.launch {
itemScope.launch {
listState.scrollToItem(
kotlin.math.min(reversedChatItems.lastIndex, indexInReversed + 1),
-maxHeightRounded
kotlin.math.min(reversedChatItems.value.lastIndex, indexInReversed + 1),
-maxHeight.value
)
}
}
@@ -1036,7 +1028,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
tryOrShowError("${cItem.id}ChatItem", error = {
CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart)
}) {
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
ChatItemView(remoteHostId, chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem.value, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp)
}
}
@@ -1044,7 +1036,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) {
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
if (it == DismissValue.DismissedToStart) {
scope.launch {
itemScope.launch {
if ((cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && chatInfo !is ChatInfo.Local) {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
@@ -1241,16 +1233,17 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
val range = chatViewItemsRange(currIndex, prevHidden)
val reversed = reversedChatItems.value
if (revealed.value && range != null) {
reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1]
reversed.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
val prev = if (index + range.first == prevHidden) prevItem else reversed[index + range.first + 1]
ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation)
}
} else {
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
}
if (i == reversedChatItems.lastIndex) {
if (i == reversed.lastIndex) {
DateSeparator(cItem.meta.itemTs)
}
}
@@ -1258,7 +1251,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
LaunchedEffect(cItem.id) {
scope.launch {
itemScope.launch {
delay(600)
markRead(CC.ItemRange(cItem.id, cItem.id), null)
}
@@ -1267,10 +1260,10 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
FloatingButtons(chatModel.chatItems, unreadCount, remoteHostId, chatInfo, searchValue, markRead, setFloatingButton, listState)
FloatingButtons(chatModel.chatItems, unreadCount, composeViewHeight, remoteHostId, chatInfo, searchValue, markRead, listState)
FloatingDate(
Modifier.padding(top = 10.dp).align(Alignment.TopCenter),
Modifier.padding(top = 10.dp + topPaddingToContent()).align(Alignment.TopCenter),
listState,
)
@@ -1325,87 +1318,65 @@ private fun ScrollToBottom(chatId: ChatId, listState: LazyListState, chatItems:
}
@Composable
fun BoxWithConstraintsScope.FloatingButtons(
fun BoxScope.FloatingButtons(
chatItems: State<List<ChatItem>>,
unreadCount: State<Int>,
composeViewHeight: State<Dp>,
remoteHostId: Long?,
chatInfo: ChatInfo,
searchValue: State<String>,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit,
listState: LazyListState
) {
val scope = rememberCoroutineScope()
var firstVisibleIndex by remember { mutableStateOf(listState.firstVisibleItemIndex) }
var lastIndexOfVisibleItems by remember { mutableStateOf(listState.layoutInfo.visibleItemsInfo.lastIndex) }
var firstItemIsVisible by remember { mutableStateOf(firstVisibleIndex == 0) }
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect {
firstVisibleIndex = it
firstItemIsVisible = firstVisibleIndex == 0
}
}
LaunchedEffect(listState) {
// When both snapshotFlows located in one LaunchedEffect second block will never be called because coroutine is paused on first block
// so separate them into two LaunchedEffects
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastIndex }
.distinctUntilChanged()
.collect {
lastIndexOfVisibleItems = it
}
}
val bottomUnreadCount by remember {
val maxHeight = remember { derivedStateOf { listState.layoutInfo.viewportSize.height } }
val bottomUnreadCount = remember {
derivedStateOf {
if (unreadCount.value == 0) return@derivedStateOf 0
val items = chatItems.value
val from = items.lastIndex - firstVisibleIndex - lastIndexOfVisibleItems
val from = items.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex
if (items.size <= from || from < 0) return@derivedStateOf 0
items.subList(from, items.size).count { it.isRcvNew }
}
}
val firstVisibleOffset = (-with(LocalDensity.current) { maxHeight.roundToPx() } * 0.8).toInt()
LaunchedEffect(bottomUnreadCount, firstItemIsVisible) {
val showButtonWithCounter = bottomUnreadCount > 0 && !firstItemIsVisible && searchValue.value.isEmpty()
val showButtonWithArrow = !showButtonWithCounter && !firstItemIsVisible
setFloatingButton(
bottomEndFloatingButton(
bottomUnreadCount,
showButtonWithCounter,
showButtonWithArrow,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount - 1), firstVisibleOffset) }
}
))
}
val showBottomButtonWithCounter = remember { derivedStateOf { bottomUnreadCount.value > 0 && listState.firstVisibleItemIndex != 0 && searchValue.value.isEmpty() } }
val showBottomButtonWithArrow = remember { derivedStateOf { !showBottomButtonWithCounter.value && listState.firstVisibleItemIndex != 0 } }
BottomEndFloatingButton(
bottomUnreadCount,
showBottomButtonWithCounter,
showBottomButtonWithArrow,
composeViewHeight,
onClickArrowDown = {
scope.launch { listState.animateScrollToItem(0) }
},
onClickCounter = {
val firstVisibleOffset = (-maxHeight.value * 0.8).toInt()
scope.launch { listState.animateScrollToItem(kotlin.math.max(0, bottomUnreadCount.value - 1), firstVisibleOffset) }
}
)
// Don't show top FAB if is in search
if (searchValue.value.isNotEmpty()) return
val fabSize = 56.dp
val topUnreadCount by remember {
derivedStateOf { unreadCount.value - bottomUnreadCount }
}
val showButtonWithCounter = topUnreadCount > 0
val height = with(LocalDensity.current) { maxHeight.toPx() }
val topUnreadCount = remember { derivedStateOf { unreadCount.value - bottomUnreadCount.value } }
val showDropDown = remember { mutableStateOf(false) }
TopEndFloatingButton(
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp).align(Alignment.TopEnd),
Modifier.padding(end = DEFAULT_PADDING, top = 24.dp + topPaddingToContent()).align(Alignment.TopEnd),
topUnreadCount,
showButtonWithCounter,
onClick = { scope.launch { listState.animateScrollBy(height) } },
onClick = { scope.launch { listState.animateScrollBy(maxHeight.value.toFloat()) } },
onLongClick = { showDropDown.value = true }
)
Box {
DefaultDropdownMenu(showDropDown, offset = DpOffset(this@FloatingButtons.maxWidth - DEFAULT_PADDING, 24.dp + fabSize)) {
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd)) {
val density = LocalDensity.current
val width = remember { mutableStateOf(250.dp) }
DefaultDropdownMenu(
showDropDown,
modifier = Modifier.onSizeChanged { with(density) { width.value = it.width.toDp().coerceAtLeast(250.dp) } },
offset = DpOffset(-DEFAULT_PADDING - width.value, 24.dp + fabSize + topPaddingToContent())
) {
ItemAction(
generalGetString(MR.strings.mark_read),
painterResource(MR.images.ic_check),
@@ -1413,7 +1384,7 @@ fun BoxWithConstraintsScope.FloatingButtons(
val minUnreadItemId = chatModel.chats.value.firstOrNull { it.remoteHostId == remoteHostId && it.id == chatInfo.id }?.chatStats?.minUnreadItemId ?: return@ItemAction
markRead(
CC.ItemRange(minUnreadItemId, chatItems.value[chatItems.value.size - listState.layoutInfo.visibleItemsInfo.lastIndex - 1].id - 1),
bottomUnreadCount
bottomUnreadCount.value
)
showDropDown.value = false
})
@@ -1475,12 +1446,11 @@ fun MemberImage(member: GroupMember) {
@Composable
private fun TopEndFloatingButton(
modifier: Modifier = Modifier,
unreadCount: Int,
showButtonWithCounter: Boolean,
unreadCount: State<Int>,
onClick: () -> Unit,
onLongClick: () -> Unit
) = when {
showButtonWithCounter -> {
unreadCount.value > 0 -> {
val interactionSource = interactionSourceWithDetection(onClick, onLongClick)
FloatingActionButton(
{}, // no action here
@@ -1490,7 +1460,7 @@ private fun TopEndFloatingButton(
interactionSource = interactionSource,
) {
Text(
unreadCountStr(unreadCount),
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
@@ -1500,6 +1470,16 @@ private fun TopEndFloatingButton(
}
}
@Composable
fun topPaddingToContent(): Dp {
val oneHandUI = remember { appPrefs.oneHandUI.state }
return if (oneHandUI.value) {
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
} else {
AppBarHeight * fontSizeSqrtMultiplier + WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
}
}
@Composable
private fun FloatingDate(
modifier: Modifier,
@@ -1509,8 +1489,9 @@ private fun FloatingDate(
var isNearBottom by remember { mutableStateOf(true) }
val lastVisibleItemDate = remember {
derivedStateOf {
if (listState.layoutInfo.visibleItemsInfo.lastIndex >= 0 && listState.firstVisibleItemIndex >= 0) {
val lastVisibleChatItemIndex = chatModel.chatItems.value.lastIndex - listState.firstVisibleItemIndex - listState.layoutInfo.visibleItemsInfo.lastIndex
if (listState.layoutInfo.visibleItemsInfo.lastIndex >= 0) {
val lastFullyVisibleOffset = listState.layoutInfo.viewportEndOffset
val lastVisibleChatItemIndex = chatModel.chatItems.value.lastIndex - (listState.layoutInfo.visibleItemsInfo.lastOrNull { item -> item.offset + item.size <= lastFullyVisibleOffset && item.size > 0 }?.index ?: 0)
val item = chatModel.chatItems.value.getOrNull(lastVisibleChatItemIndex)
val timeZone = TimeZone.currentSystemDefault()
item?.meta?.itemTs?.toLocalDateTime(timeZone)?.date?.atStartOfDayIn(timeZone)
@@ -1697,48 +1678,44 @@ fun openGroupLink(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (
}
}
private fun bottomEndFloatingButton(
unreadCount: Int,
showButtonWithCounter: Boolean,
showButtonWithArrow: Boolean,
@Composable
private fun BoxScope.BottomEndFloatingButton(
unreadCount: State<Int>,
showButtonWithCounter: State<Boolean>,
showButtonWithArrow: State<Boolean>,
composeViewHeight: State<Dp>,
onClickArrowDown: () -> Unit,
onClickCounter: () -> Unit
): @Composable () -> Unit = when {
showButtonWithCounter -> {
{
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Text(
unreadCountStr(unreadCount),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
) = when {
showButtonWithCounter.value -> {
FloatingActionButton(
onClick = onClickCounter,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Text(
unreadCountStr(unreadCount.value),
color = MaterialTheme.colors.primary,
fontSize = 14.sp,
)
}
}
showButtonWithArrow -> {
{
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Icon(
painter = painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
showButtonWithArrow.value -> {
FloatingActionButton(
onClick = onClickArrowDown,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
modifier = Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + composeViewHeight.value).align(Alignment.BottomEnd).size(48.dp),
backgroundColor = MaterialTheme.colors.secondaryVariant,
) {
Icon(
painter = painterResource(MR.images.ic_keyboard_arrow_down),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
}
else -> {
{}
}
else -> {}
}
@Composable
@@ -1865,6 +1842,25 @@ private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount:
}
}
fun Modifier.chatViewBackgroundModifier(
colors: Colors,
wallpaper: AppWallpaper,
backgroundGraphicsLayerSize: MutableState<IntSize>?,
backgroundGraphicsLayer: GraphicsLayer?
): Modifier {
val wallpaperImage = wallpaper.type.image
val wallpaperType = wallpaper.type
val backgroundColor = wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background)
val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base)
return this
.then(if (wallpaperImage != null)
Modifier.drawWithCache { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, backgroundGraphicsLayerSize, backgroundGraphicsLayer) }
else
Modifier.drawWithCache { onDrawBehind { copyBackgroundToAppBar(backgroundGraphicsLayerSize, backgroundGraphicsLayer) { drawRect(backgroundColor) } } }
)
}
fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? =
if (currIndex != null && prevHidden != null && prevHidden > currIndex) {
currIndex..prevHidden
@@ -13,12 +13,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.filesToDelete
import chat.simplex.common.model.ChatModel.withChats
@@ -896,7 +898,7 @@ fun ComposeView(
}
}
}
Column(Modifier.background(MaterialTheme.colors.background)) {
Box(Modifier.background(MaterialTheme.colors.background)) {
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
@@ -918,7 +920,7 @@ fun ComposeView(
&& !nextSendGrpInv.value
IconButton(
attachmentClicked,
Modifier.padding(bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
enabled = attachmentEnabled
) {
Icon(
@@ -81,10 +81,7 @@ private fun ContactPreferencesLayout(
reset: () -> Unit,
savePrefs: () -> Unit,
) {
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.contact_preferences))
val timedMessages: MutableState<Boolean> = remember(featuresAllowed) { mutableStateOf(featuresAllowed.timedMessagesAllowed) }
val onTTLUpdated = { ttl: Int? ->
@@ -1,9 +1,11 @@
package chat.simplex.common.views.chat
import SectionBottomSpacer
import androidx.compose.foundation.layout.*
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCodeScanner
@@ -12,9 +14,7 @@ import dev.icerock.moko.resources.compose.stringResource
@Composable
fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: () -> Unit) {
Column(
Modifier.fillMaxSize()
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.scan_code))
QRCodeScanner { text ->
verifyCode(text) {
@@ -28,5 +28,6 @@ fun ScanCodeView(verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit, close: ()
}
}
Text(stringResource(MR.strings.scan_code_from_contacts_app), Modifier.padding(horizontal = DEFAULT_PADDING))
SectionBottomSpacer()
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.BackHandler
import chat.simplex.common.platform.chatModel
import chat.simplex.common.views.helpers.*
@@ -20,11 +21,12 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
val onBackClicked = { selectedChatItems.value = null }
BackHandler(onBack = onBackClicked)
val count = selectedChatItems.value?.size ?: 0
DefaultTopAppBar(
val oneHandUI = remember { appPrefs.oneHandUI.state }
DefaultAppBar(
navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) },
title = {
Text(
@@ -39,10 +41,9 @@ fun SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
)
},
onTitleClick = null,
showSearch = false,
onTop = !oneHandUI.value,
onSearchValueChanged = {},
)
Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier))
}
@Composable
@@ -68,6 +69,8 @@ fun SelectedItemsBottomToolbar(
Modifier
.matchParentSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 2.dp)
.height(AppBarHeight * fontSizeSqrtMultiplier)
.pointerInput(Unit) {
detectGesture {
true
@@ -103,6 +106,7 @@ fun SelectedItemsBottomToolbar(
)
}
}
Divider(Modifier.align(Alignment.TopStart))
}
LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) {
recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
@@ -7,7 +7,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
@@ -15,6 +14,7 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.*
@@ -61,7 +61,8 @@ fun SendMsgView(
) {
val showCustomDisappearingMessageDialog = remember { mutableStateOf(false) }
Box(Modifier.padding(vertical = if (appPlatform.isAndroid) 8.dp else 6.dp)) {
val padding = if (appPlatform.isAndroid) PaddingValues(vertical = 8.dp) else PaddingValues(top = 3.dp, bottom = 4.dp)
Box(Modifier.padding(padding)) {
val cs = composeState.value
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(composeState.value.inProgress) {
@@ -147,7 +148,7 @@ fun SendMsgView(
&& (cs.preview !is ComposePreview.VoicePreview || !stopRecOnNextClick.value)
&& cs.contextItem is ComposeContextItem.NoContextItem
) {
Spacer(Modifier.width(10.dp))
Spacer(Modifier.width(12.dp))
StartLiveMessageButton(userCanSend) {
if (composeState.value.preview is ComposePreview.NoPreview) {
startLiveMessage(scope, sendLiveMessage, updateLiveMessage, sendButtonSize, sendButtonAlpha, composeState, liveMessageAlertShown)
@@ -423,6 +424,7 @@ private fun SendMsgButton(
onLongClick: (() -> Unit)? = null
) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
Box(
modifier = Modifier.requiredSize(36.dp)
.combinedClickable(
@@ -431,7 +433,7 @@ private fun SendMsgButton(
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
indication = ripple
)
.onRightClick { onLongClick?.invoke() },
contentAlignment = Alignment.Center
@@ -454,6 +456,7 @@ private fun SendMsgButton(
@Composable
private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
val interactionSource = remember { MutableInteractionSource() }
val ripple = remember { ripple(bounded = false, radius = 24.dp) }
Box(
modifier = Modifier.requiredSize(36.dp)
.clickable(
@@ -461,7 +464,7 @@ private fun StartLiveMessageButton(enabled: Boolean, onClick: () -> Unit) {
enabled = enabled,
role = Role.Button,
interactionSource = interactionSource,
indication = rememberRipple(bounded = false, radius = 24.dp)
indication = ripple
),
contentAlignment = Alignment.Center
) {
@@ -56,11 +56,7 @@ private fun VerifyCodeLayout(
connectionVerified: Boolean,
verifyCode: (String?, cb: (Boolean) -> Unit) -> Unit,
) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.padding(horizontal = DEFAULT_PADDING)
) {
ColumnWithScrollBar(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.security_code), withPadding = false)
val splitCode = splitToParts(connectionCode, 24)
Row(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF), horizontalArrangement = Arrangement.Center) {
@@ -130,10 +130,7 @@ fun AddGroupMembersLayout(
}
}
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.button_add_members))
profileText()
Spacer(Modifier.size(DEFAULT_PADDING))
@@ -25,6 +25,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@@ -283,9 +284,14 @@ fun ModalData.GroupChatInfoLayout(
if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) }
}
}
Box {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LazyColumnWithScrollBar(
Modifier
.fillMaxWidth(),
contentPadding = if (oneHandUI.value) {
PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
} else {
PaddingValues(top = topPaddingToContent())
},
state = listState
) {
item {
@@ -397,6 +403,11 @@ fun ModalData.GroupChatInfoLayout(
}
}
SectionBottomSpacer()
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
if (!oneHandUI.value) {
NavigationBarBackground(oneHandUI.value, oneHandUI.value)
}
}
}
@@ -119,9 +119,7 @@ fun GroupLinkLayout(
)
}
ColumnWithScrollBar(
Modifier,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.group_link))
Text(
stringResource(MR.strings.you_can_share_group_link_anybody_will_be_able_to_connect),
@@ -313,10 +313,7 @@ fun GroupMemberInfoLayout(
}
}
ColumnWithScrollBar(
Modifier
.fillMaxWidth(),
) {
ColumnWithScrollBar {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
@@ -82,9 +82,7 @@ private fun GroupPreferencesLayout(
reset: () -> Unit,
savePrefs: () -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.group_preferences))
val timedMessages = remember(preferences) { mutableStateOf(preferences.timedMessages.enable) }
val onTTLUpdated = { ttl: Int? ->
@@ -82,10 +82,9 @@ fun GroupProfileLayout(
}, close)
}
}
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
scrimColor = Color.Black.copy(alpha = 0.12F),
modifier = Modifier.navigationBarsWithImePadding(),
modifier = Modifier.imePadding(),
sheetContent = {
GetImageBottomSheet(
chosenImage,
@@ -98,9 +97,7 @@ fun GroupProfileLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
ModalView(close = closeWithAlert) {
ColumnWithScrollBar(
Modifier
) {
ColumnWithScrollBar {
Column(
Modifier.fillMaxWidth()
.padding(horizontal = DEFAULT_PADDING)
@@ -177,7 +174,6 @@ fun GroupProfileLayout(
}
}
}
}
}
private fun canUpdateProfile(displayName: String, groupProfile: GroupProfile): Boolean =
@@ -95,9 +95,7 @@ private fun GroupWelcomeLayout(
linkMode: SimplexLinkMode,
save: () -> Unit,
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
val editMode = remember { mutableStateOf(true) }
AppBarTitle(stringResource(MR.strings.group_welcome_title))
val wt = rememberSaveable { welcomeText }
@@ -10,6 +10,7 @@ import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.layout.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.UriHandler
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -320,6 +321,8 @@ fun CIMarkdownText(
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble"
const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose"
const val CONSOLE_COMPOSE_LAYOUT_ID = "consoleCompose"
/**
* Equal to [androidx.compose.ui.unit.Constraints.MaxFocusMask], which is 0x3FFFF - 1
* Other values make a crash `java.lang.IllegalArgumentException: Can't represent a width of 123456 and height of 9909 in Constraints`
@@ -398,6 +401,70 @@ fun DependentLayout(
}
}
}
// The purpose of this layout is to make measuring of bottom compose view and adapt top lazy column to its size in the same frame (not on the next frame as you would expect).
// So, steps are:
// - measuring the layout: measured height of compose view before this step is 0, it's added to content padding of lazy column (so it's == 0)
// - measured the layout: measured height of compose view now is correct, but it's not yet applied to lazy column content padding (so it's == 0) and lazy column is placed higher than compose view in view with respect to compose view's height
// - on next frame measured height is correct and content padding is the same, lazy column placed to occupy all parent view's size
// - every added/removed line in compose view goes through the same process.
@Composable
fun AdaptingBottomPaddingLayout(
modifier: Modifier = Modifier,
mainLayoutId: String,
expectedHeight: MutableState<Dp>,
content: @Composable () -> Unit
) {
val expected = with(LocalDensity.current) { expectedHeight.value.roundToPx() }
Layout(
content = content,
modifier = modifier
) { measureable, constraints ->
require(measureable.size <= 2) { "Should be exactly one or two elements in this layout, you have ${measureable.size}" }
val mainPlaceable = measureable.firstOrNull { it.layoutId == mainLayoutId }!!.measure(constraints)
val placeables: List<Placeable> = measureable.map {
if (it.layoutId == mainLayoutId)
mainPlaceable
else
it.measure(constraints.copy(maxHeight = if (expected != mainPlaceable.measuredHeight) constraints.maxHeight - mainPlaceable.measuredHeight + expected else constraints.maxHeight)) }
expectedHeight.value = mainPlaceable.measuredHeight.toDp()
layout(constraints.maxWidth, constraints.maxHeight) {
var y = 0
placeables.forEach {
if (it !== mainPlaceable) {
it.place(0, y)
y += it.measuredHeight
} else {
it.place(0, constraints.maxHeight - mainPlaceable.measuredHeight)
y += it.measuredHeight
}
}
}
}
}
@Composable
fun CenteredRowLayout(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Layout(
content = content,
modifier = modifier
) { measureable, constraints ->
require(measureable.size == 3) { "Should be exactly three elements in this layout, you have ${measureable.size}" }
val first = measureable[0].measure(constraints.copy(minWidth = 0, minHeight = 0))
val third = measureable[2].measure(constraints.copy(minWidth = first.measuredWidth, minHeight = 0))
val second = measureable[1].measure(constraints.copy(minWidth = 0, minHeight = 0, maxWidth = (constraints.maxWidth - first.measuredWidth - third.measuredWidth).coerceAtLeast(0)))
// Limit width for every other element to width of important element and height for a sum of all elements.
layout(constraints.maxWidth, constraints.maxHeight) {
first.place(0, ((constraints.maxHeight - first.measuredHeight) / 2).coerceAtLeast(0))
second.place((constraints.maxWidth - second.measuredWidth) / 2, ((constraints.maxHeight - second.measuredHeight) / 2).coerceAtLeast(0))
third.place(constraints.maxWidth - third.measuredWidth, ((constraints.maxHeight - third.measuredHeight) / 2).coerceAtLeast(0))
}
}
}
/*
class EditedProvider: PreviewParameterProvider<Boolean> {
@@ -12,7 +12,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.layout.onGloballyPositioned
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.CryptoFile
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.CurrentColors
@@ -58,9 +57,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
val playersToRelease = rememberSaveable { mutableSetOf<URI>() }
DisposableEffectOnGone(
always = {
platform.androidSetStatusAndNavBarColors(CurrentColors.value.colors.isLight, Color.Black, false, false)
platform.androidSetStatusAndNavigationBarAppearance(false, false, blackNavBar = true)
chatModel.fullscreenGalleryVisible.value = true
},
whenGone = { playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) } }
whenDispose = {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavigationBarAppearance(c.isLight, c.isLight)
chatModel.fullscreenGalleryVisible.value = false
},
whenGone = {
playersToRelease.forEach { VideoPlayerHolder.release(it, true, true) }
}
)
@Composable
@@ -10,8 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.*
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
@@ -34,6 +33,7 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chat.item.CIFileViewScope
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
@@ -41,7 +41,6 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import java.net.URI
import kotlin.time.Duration.Companion.seconds
private fun showNewChatSheet(oneHandUI: State<Boolean>) {
@@ -55,7 +54,7 @@ private fun showNewChatSheet(oneHandUI: State<Boolean>) {
chatModel.newChatSheetVisible.value = false
close()
}
ModalView(close, closeOnTop = !oneHandUI.value) {
ModalView(close, showAppBar = !oneHandUI.value) {
if (appPlatform.isAndroid) {
BackHandler {
close()
@@ -122,11 +121,7 @@ fun ToggleChatListCard() {
SharedPreferenceToggle(
appPrefs.oneHandUI,
enabled = true,
onChange = {
val c = CurrentColors.value.colors
platform.androidSetStatusAndNavBarColors(c.isLight, c.background, !appPrefs.oneHandUI.get(), appPrefs.oneHandUI.get())
}
enabled = true
)
}
}
@@ -154,74 +149,36 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
}
}
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
Scaffold(
topBar = {
if (!oneHandUI.value) {
Column {
ChatListToolbar(
userPickerState,
stopped,
setPerformLA,
)
Divider()
}
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
Box(Modifier.fillMaxSize()) {
if (oneHandUI.value) {
ChatListWithLoadingScreen(searchText, listState)
Column(Modifier.align(Alignment.BottomCenter)) {
ChatListToolbar(
userPickerState,
listState,
stopped,
setPerformLA,
)
}
},
bottomBar = {
if (oneHandUI.value) {
Column {
Divider()
ChatListToolbar(
userPickerState,
stopped,
setPerformLA,
)
}
} else {
ChatListWithLoadingScreen(searchText, listState)
Column {
ChatListToolbar(
userPickerState,
listState,
stopped,
setPerformLA,
)
}
},
contentColor = LocalContentColor.current,
floatingActionButton = {
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
showNewChatSheet(oneHandUI)
}
},
Modifier
.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
.size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(22.dp * fontSizeSqrtMultiplier))
}
}
}
) {
Box(Modifier.padding(it)) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
}
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
}
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
NewChatSheetFloatingButton(oneHandUI, stopped)
}
}
}
if (searchText.value.text.isEmpty()) {
if (appPlatform.isDesktop) {
if (appPlatform.isDesktop && !oneHandUI.value) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call)
@@ -239,6 +196,46 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
}
}
@Composable
private fun BoxScope.ChatListWithLoadingScreen(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(searchText = searchText, listState)
}
if (chatModel.chats.value.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(
stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats
), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary
)
}
}
@Composable
private fun BoxScope.NewChatSheetFloatingButton(oneHandUI: State<Boolean>, stopped: Boolean) {
FloatingActionButton(
onClick = {
if (!stopped) {
showNewChatSheet(oneHandUI)
}
},
Modifier
.navigationBarsPadding()
.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING)
.align(Alignment.BottomEnd)
.size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(22.dp * fontSizeSqrtMultiplier))
}
}
@Composable
private fun ConnectButton(text: String, onClick: () -> Unit) {
Button(
@@ -256,7 +253,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, listState: LazyListState, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value
@@ -265,6 +262,18 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
if (oneHandUI.value) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
if (appPlatform.isDesktop && oneHandUI.value) {
val call = remember { chatModel.activeCall }
if (call.value != null) {
barButtons.add {
val c = call.value
if (c != null) {
ActiveCallInteractiveArea(c)
Spacer(Modifier.width(5.dp))
}
}
}
}
if (!stopped) {
barButtons.add {
IconButton(
@@ -323,7 +332,9 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
}
}
val clipboard = LocalClipboardManager.current
DefaultTopAppBar(
val scope = rememberCoroutineScope()
val canScrollToZero = remember { derivedStateOf { listState.firstVisibleItemIndex != 0 || listState.firstVisibleItemScrollOffset != 0 } }
DefaultAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu {
@@ -351,15 +362,14 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
SubscriptionStatusIndicator(
click = {
ModalManager.start.closeModals()
val summary = serversSummary.value
ModalManager.start.showModalCloseable(
endButtons = {
val summary = serversSummary.value
if (summary != null) {
ShareButton {
val json = Json {
prettyPrint = true
}
val text = json.encodeToString(PresentedServersSummary.serializer(), summary)
clipboard.shareText(text)
}
@@ -370,10 +380,10 @@ private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>
)
}
},
onTitleClick = null,
showSearch = false,
onTitleClick = if (canScrollToZero.value) { { scrollToBottom(scope, listState) } } else null,
onTop = !oneHandUI.value,
onSearchValueChanged = {},
buttons = barButtons
buttons = { barButtons.forEach { it() } }
)
}
@@ -491,74 +501,78 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: String, chatModel: ChatModel) {
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
alwaysVisible = true,
searchText = searchText,
enabled = !remember { searchShowingSimplexLink }.value,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
Box {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(
painterResource(MR.images.ic_search),
contentDescription = null,
Modifier.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF).size(22.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
alwaysVisible = true,
searchText = searchText,
enabled = !remember { searchShowingSimplexLink }.value,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
} else {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.value.isNotEmpty()) {
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts)
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
}
} else {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.value.isNotEmpty()) {
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
val link = strHasSingleSimplexLink(it.trim())
if (link != null) {
// if SimpleX link is pasted, show connection dialogue
hideKeyboard(view)
if (link.format is Format.SimplexLink) {
val linkText = link.simplexLinkText(link.format.linkType, link.format.smpHosts)
searchText.value = searchText.value.copy(linkText, selection = TextRange.Zero)
}
searchShowingSimplexLink.value = true
searchChatFilteredBySimplexLink.value = null
connect(link.text, searchChatFilteredBySimplexLink) { searchText.value = TextFieldValue() }
} else if (!searchShowingSimplexLink.value || it.isEmpty()) {
if (it.isNotEmpty()) {
// if some other text is pasted, enter search mode
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
searchShowingSimplexLink.value = false
searchChatFilteredBySimplexLink.value = null
}
}
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
Divider(Modifier.align(if (oneHandUI.value) Alignment.TopStart else Alignment.BottomStart))
}
}
@@ -590,8 +604,37 @@ enum class ScrollDirection {
}
@Composable
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
fun BoxScope.StatusBarBackground() {
if (appPlatform.isAndroid) {
val finalColor = MaterialTheme.colors.background.copy(0.88f)
Box(Modifier.fillMaxWidth().windowInsetsTopHeight(WindowInsets.statusBars).background(finalColor))
}
}
@Composable
fun BoxScope.NavigationBarBackground(appBarOnBottom: Boolean = false, mixedColor: Boolean, noAlpha: Boolean = false) {
if (appPlatform.isAndroid) {
val barPadding = WindowInsets.navigationBars.asPaddingValues()
val paddingBottom = barPadding.calculateBottomPadding()
val color = if (mixedColor) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f) else MaterialTheme.colors.background
val finalColor = color.copy(if (noAlpha) 1f else if (appBarOnBottom) remember { appPrefs.inAppBarsAlpha.state }.value else 0.6f)
Box(Modifier.align(Alignment.BottomStart).height(paddingBottom).fillMaxWidth().background(finalColor))
}
}
@Composable
fun BoxScope.NavigationBarBackground(modifier: Modifier, color: Color = MaterialTheme.colors.background) {
val keyboardState = getKeyboardState()
if (appPlatform.isAndroid && keyboardState.value == KeyboardState.Closed) {
val barPadding = WindowInsets.navigationBars.asPaddingValues()
val paddingBottom = barPadding.calculateBottomPadding()
val finalColor = color.copy(0.6f)
Box(modifier.align(Alignment.BottomStart).height(paddingBottom).fillMaxWidth().background(finalColor))
}
}
@Composable
private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listState: LazyListState) {
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
@@ -628,40 +671,45 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
val searchShowingSimplexLink = remember { mutableStateOf(false) }
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList())
val topPaddingToContent = topPaddingToContent()
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
LazyColumnWithScrollBar(
Modifier.fillMaxSize(),
if (!oneHandUI.value) Modifier.imePadding() else Modifier,
listState,
reverseLayout = oneHandUI.value
) {
item { Spacer(Modifier.height(blankSpaceSize)) }
stickyHeader {
Column(
Modifier
.zIndex(1f)
.offset {
val y = if (searchText.value.text.isEmpty()) {
val offsetMultiplier = if (oneHandUI.value) 1 else -1
if (
(oneHandUI.value && scrollDirection == ScrollDirection.Up) ||
(appPlatform.isAndroid && keyboardState == KeyboardState.Opened)
) {
0
} else if (listState.firstVisibleItemIndex == 0) offsetMultiplier * listState.firstVisibleItemScrollOffset else offsetMultiplier * 1000
val offsetMultiplier = if (oneHandUI.value) 1 else -1
val y = if (searchText.value.text.isNotEmpty() || (appPlatform.isAndroid && keyboardState == KeyboardState.Opened) || scrollDirection == ScrollDirection.Up) {
if (listState.firstVisibleItemIndex == 0) -offsetMultiplier * listState.firstVisibleItemScrollOffset
else -offsetMultiplier * blankSpaceSize.roundToPx()
} else {
0
when (listState.firstVisibleItemIndex) {
0 -> 0
1 -> offsetMultiplier * listState.firstVisibleItemScrollOffset
else -> offsetMultiplier * 1000
}
}
IntOffset(0, y)
}
.background(MaterialTheme.colors.background),
.background(MaterialTheme.colors.background)
) {
if (oneHandUI.value) {
Divider()
}
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
if (!oneHandUI.value) {
Divider()
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
} else {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
}
}
}
if (appPlatform.isAndroid && !oneHandUICardShown.value && chats.count() > 1) {
if (!oneHandUICardShown.value && chats.size > 1) {
item {
ToggleChatListCard()
}
@@ -672,17 +720,30 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
} }
ChatListNavLinkView(chat, nextChatSelected)
}
if (appPlatform.isAndroid && !oneHandUICardShown.value && chats.count() <= 1) {
if (!oneHandUICardShown.value && chats.size <= 1) {
item {
ToggleChatListCard()
}
}
if (appPlatform.isAndroid) {
item { Spacer(if (oneHandUI.value) Modifier.windowInsetsTopHeight(WindowInsets.statusBars) else Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars)) }
}
}
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
}
}
if (oneHandUI.value) {
StatusBarBackground()
} else {
NavigationBarBackground(oneHandUI.value, true)
}
if (!oneHandUICardShown.value) {
LaunchedEffect(chats.size) {
if (chats.size >= 3) appPrefs.oneHandUICardShown.set(true)
}
}
}
fun filteredChats(
@@ -727,3 +788,7 @@ private fun filtered(chat: Chat): Boolean =
(chat.chatInfo.chatSettings?.favorite ?: false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } }
}
@@ -620,9 +620,7 @@ fun ModalData.SMPServerSummaryView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.smp_server),
@@ -645,9 +643,7 @@ fun ModalData.DetailedXFTPStatsView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
@@ -671,9 +667,7 @@ fun ModalData.DetailedSMPStatsView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
@@ -697,9 +691,7 @@ fun ModalData.XFTPServerSummaryView(
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
@@ -715,9 +707,7 @@ fun ModalData.XFTPServerSummaryView(
@Composable
fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableState<PresentedServersSummary?>) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
ColumnWithScrollBar {
var showUserSelection by remember { mutableStateOf(false) }
val selectedUserCategory =
remember { stateGetOrPut("selectedUserCategory") { PresentedUserCategory.ALL_USERS } }
@@ -11,10 +11,13 @@ import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.themedBackground
import chat.simplex.common.views.chat.topPaddingToContent
import chat.simplex.common.views.newchat.ActiveProfilePicker
import chat.simplex.res.MR
@@ -22,26 +25,7 @@ import chat.simplex.res.MR
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val oneHandUI = remember { appPrefs.oneHandUI.state }
Scaffold(
contentColor = LocalContentColor.current,
topBar = {
if (!oneHandUI.value) {
Column {
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
Divider()
}
}
},
bottomBar = {
if (oneHandUI.value) {
Column {
Divider()
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
}
}
}
) {
Box(Modifier.fillMaxSize().themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
val sharedContent = chatModel.sharedContent.value
var isMediaOrFileAttachment = false
var isVoice = false
@@ -69,22 +53,24 @@ fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
}
null -> {}
}
Box(Modifier.padding(it)) {
Column(
modifier = Modifier.fillMaxSize()
) {
if (chatModel.chats.value.isNotEmpty()) {
ShareList(
chatModel,
search = searchInList,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink,
)
} else {
EmptyList()
}
}
if (chatModel.chats.value.isNotEmpty()) {
ShareList(
chatModel,
search = searchInList,
isMediaOrFileAttachment = isMediaOrFileAttachment,
isVoice = isVoice,
hasSimplexLink = hasSimplexLink,
)
} else {
EmptyList()
}
if (oneHandUI.value) {
StatusBarBackground()
} else {
NavigationBarBackground(oneHandUI.value, true)
}
Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) {
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
}
}
}
@@ -108,7 +94,6 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
if (showSearch) {
BackHandler(onBack = hideSearchOnBack)
}
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val navButton: @Composable RowScope.() -> Unit = {
when {
@@ -118,13 +103,13 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
ModalManager.start.showCustomModal { close ->
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
showSearch = true,
searchAlwaysVisible = true,
onSearchValueChanged = { search.value = it },
content = {
ActiveProfilePicker(
search = search,
@@ -148,31 +133,8 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
})
}
}
if (chatModel.chats.value.size >= 8) {
barButtons.add {
IconButton({ showSearch = true }) {
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
}
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
DefaultTopAppBar(
DefaultAppBar(
navigationButton = navButton,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -191,8 +153,29 @@ private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchVal
},
onTitleClick = null,
showSearch = showSearch,
onTop = !remember { appPrefs.oneHandUI.state }.value,
onSearchValueChanged = onSearchValueChanged,
buttons = barButtons
buttons = {
if (chatModel.chats.value.size >= 8) {
IconButton({ showSearch = true }) {
Icon(painterResource(MR.images.ic_search_500), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
if (stopped) {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
)
}
@@ -211,8 +194,13 @@ private fun ShareList(
filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted)
}
}
val topPaddingToContent = topPaddingToContent()
LazyColumnWithScrollBar(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.then(if (oneHandUI.value) Modifier.consumeWindowInsets(WindowInsets.navigationBars.only(WindowInsetsSides.Vertical)) else Modifier).imePadding(),
contentPadding = PaddingValues(
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent,
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
),
reverseLayout = oneHandUI.value
) {
items(chats) { chat ->
@@ -23,6 +23,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
@@ -137,12 +138,16 @@ fun UserPicker(
}
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
val iconColor = MaterialTheme.colors.secondaryVariant
val background = if (appPlatform.isAndroid) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha()) else MaterialTheme.colors.surface
PlatformUserPicker(
modifier = Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
.background(if (appPlatform.isAndroid) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha()) else MaterialTheme.colors.surface)
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true, ambientColor = background) else Modifier)
.padding(top = if (appPlatform.isDesktop && oneHandUI.value) 7.dp else 0.dp)
.background(background)
.padding(bottom = USER_PICKER_SECTION_SPACING - DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
pickerState = userPickerState
) {
@@ -198,12 +203,13 @@ fun UserPicker(
UserPickerUsersSection(
users = users,
onUserClicked = onUserClicked,
iconColor = iconColor,
stopped = stopped
)
}
} else if (currentUser != null) {
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(currentUser.profile, stopped = stopped)
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped)
}
}
}
@@ -234,6 +240,7 @@ fun UserPicker(
Column(modifier = Modifier.padding(vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)) {
UserPickerUsersSection(
users = inactiveUsers,
iconColor = iconColor,
onUserClicked = onUserClicked,
stopped = stopped
)
@@ -265,13 +272,15 @@ fun UserPicker(
generalGetString(MR.strings.auth_open_chat_profiles),
generalGetString(MR.strings.auth_log_in_using_credential)
) {
ModalManager.start.showCustomModal { close ->
ModalManager.start.showCustomModal(keyboardCoversBar = false) { close ->
val search = rememberSaveable { mutableStateOf("") }
val profileHidden = rememberSaveable { mutableStateOf(false) }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
showSearch = true,
searchAlwaysVisible = true,
onSearchValueChanged = {
search.value = it
},
content = { UserProfilesView(chatModel, search, profileHidden) })
}
@@ -519,6 +528,7 @@ private fun DevicePickerRow(
@Composable
expect fun UserPickerUsersSection(
users: List<UserInfo>,
iconColor: Color,
stopped: Boolean,
onUserClicked: (user: User) -> Unit,
)
@@ -46,9 +46,7 @@ fun ChatArchiveLayout(
saveArchive: () -> Unit,
deleteArchiveAlert: () -> Unit
) {
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(title)
SectionView(stringResource(MR.strings.chat_archive_section)) {
SettingsActionItem(
@@ -203,7 +203,7 @@ fun DatabaseEncryptionLayout(
Layout()
}
} else {
ColumnWithScrollBar(Modifier.fillMaxWidth(), maxIntrinsicSize = true) {
ColumnWithScrollBar(maxIntrinsicSize = true) {
Layout()
}
}
@@ -77,10 +77,7 @@ fun DatabaseErrorView(
Text(String.format(generalGetString(MR.strings.database_migrations), ms.joinToString(", ")))
}
ColumnWithScrollBar(
Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
) {
ColumnWithScrollBarNoAppBar(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
val buttonEnabled = validKey(dbKey.value) && !progressIndicator.value
when (val status = chatDbStatus.value) {
is DBMigrationResult.ErrorNotADatabase ->
@@ -156,9 +156,7 @@ fun DatabaseLayout(
val stopped = !runChat
val operationsDisabled = (!stopped || progressIndicator) && !chatModel.desktopNoUserNoRemote
ColumnWithScrollBar(
Modifier.fillMaxWidth(),
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.your_chat_database))
if (!chatModel.desktopNoUserNoRemote) {
@@ -0,0 +1,71 @@
package chat.simplex.common.views.helpers
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.geometry.Offset
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
val handler = LocalAppBarHandler.current
val connection = handler?.connection
LaunchedEffect(title) {
handler?.title?.value = title
}
val theme = CurrentColors.collectAsState()
val titleColor = MaterialTheme.appColors.title
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
else // color is not updated when changing themes if I pass null here
Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
Column {
Text(
title,
Modifier
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, top = DEFAULT_PADDING_HALF, end = if (withPadding) DEFAULT_PADDING else 0.dp,)
.graphicsLayer {
alpha = bottomTitleAlpha(connection)
},
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1.copy(brush = brush),
color = MaterialTheme.colors.primaryVariant,
textAlign = TextAlign.Start
)
if (hostDevice != null) {
Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer {
alpha = bottomTitleAlpha(connection)
}) {
HostDeviceTitle(hostDevice)
}
}
Spacer(Modifier.height(bottomPadding))
}
}
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
@Composable
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
DevicePill(
active = true,
onClick = {},
actionButtonVisible = false,
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
text = hostDevice.second
)
}
}
@@ -0,0 +1,139 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.runtime.State
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.clipRect
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.CurrentColors
fun Modifier.blurredBackgroundModifier(
keyboardInset: WindowInsets,
handler: AppBarHandler?,
blurRadius: State<Int>,
prefAlpha: State<Float>,
keyboardCoversBar: Boolean,
onTop: Boolean,
density: Density
): Modifier {
val graphicsLayer = handler?.graphicsLayer
val backgroundGraphicsLayer = handler?.backgroundGraphicsLayer
val backgroundGraphicsLayerSize = handler?.backgroundGraphicsLayerSize
if (handler == null || graphicsLayer == null || backgroundGraphicsLayer == null || blurRadius.value == 0 || prefAlpha.value == 1f || backgroundGraphicsLayerSize === null)
return this
return if (appPlatform.isAndroid) {
this.androidBlurredModifier(keyboardInset, blurRadius.value, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
} else {
this.desktopBlurredModifier(keyboardInset, blurRadius, keyboardCoversBar, onTop, graphicsLayer, backgroundGraphicsLayer, backgroundGraphicsLayerSize, density)
}
}
// this is more performant version than for Android but can't be used on desktop because on first frame it shows transparent view
// which is very noticeable on desktop and unnoticeable on Android
private fun Modifier.androidBlurredModifier(
keyboardInset: WindowInsets,
blurRadius: Int,
keyboardCoversBar: Boolean,
onTop: Boolean,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius > 0) BlurEffect(blurRadius.dp.toPx(), blurRadius.dp.toPx()) else null
clip = blurRadius > 0
}
.graphicsLayer {
if (!onTop) {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translationY = -bgSize + size.height + keyboardHeightCovered
}
}
.drawBehind {
drawRect(Color.Black)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
} else {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
}
.graphicsLayer {
if (!onTop) {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translationY -= -bgSize + size.height + keyboardHeightCovered
}
}
private fun Modifier.desktopBlurredModifier(
keyboardInset: WindowInsets,
blurRadius: State<Int>,
keyboardCoversBar: Boolean,
onTop: Boolean,
graphicsLayer: GraphicsLayer,
backgroundGraphicsLayer: GraphicsLayer,
backgroundGraphicsLayerSize: State<IntSize>,
density: Density
): Modifier = this
.graphicsLayer {
renderEffect = if (blurRadius.value > 0) BlurEffect(blurRadius.value.dp.toPx(), blurRadius.value.dp.toPx()) else null
clip = blurRadius.value > 0
}
.drawBehind {
drawRect(Color.Black)
if (onTop) {
clipRect {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
} else {
val bgSize = when {
backgroundGraphicsLayerSize.value.height == 0 && backgroundGraphicsLayer.size.height != 0 -> backgroundGraphicsLayer.size.height
backgroundGraphicsLayerSize.value.height == 0 -> graphicsLayer.size.height
else -> backgroundGraphicsLayerSize.value.height
}
val keyboardHeightCovered = if (!keyboardCoversBar) keyboardInset.getBottom(density) else 0
translate(top = -bgSize + size.height + keyboardHeightCovered) {
if (backgroundGraphicsLayer.size != IntSize.Zero) {
drawLayer(backgroundGraphicsLayer)
} else {
drawRect(CurrentColors.value.colors.background, size = Size(graphicsLayer.size.width.toFloat(), graphicsLayer.size.height.toFloat()))
}
drawLayer(graphicsLayer)
}
}
}
@@ -1,11 +1,13 @@
package chat.simplex.common.views.helpers
import androidx.compose.runtime.*
import androidx.compose.ui.draw.CacheDrawScope
import androidx.compose.ui.draw.DrawResult
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.drawscope.*
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
@@ -381,7 +383,14 @@ private fun drawToBitmap(image: ImageBitmap, imageScale: Float, tint: Color, siz
return bitmap
}
fun CacheDrawScope.chatViewBackground(image: ImageBitmap, imageType: WallpaperType, background: Color, tint: Color): DrawResult {
fun CacheDrawScope.chatViewBackground(
image: ImageBitmap,
imageType: WallpaperType,
background: Color,
tint: Color,
graphicsLayerSize: MutableState<IntSize>? = null,
backgroundGraphicsLayer: GraphicsLayer? = null
): DrawResult {
val imageScale = if (imageType is WallpaperType.Preset) {
(imageType.scale ?: 1f) * imageType.predefinedImageScale
} else if (imageType is WallpaperType.Image && imageType.scaleType == WallpaperScaleType.REPEAT) {
@@ -396,53 +405,55 @@ fun CacheDrawScope.chatViewBackground(image: ImageBitmap, imageType: WallpaperTy
}
return onDrawBehind {
val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low
drawRect(background)
when (imageType) {
is WallpaperType.Preset -> drawImage(image)
is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) {
WallpaperScaleType.REPEAT -> drawImage(image)
WallpaperScaleType.FILL, WallpaperScaleType.FIT -> {
clipRect {
val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height))
val scaledWidth = (image.width * scale.scaleX).roundToInt()
val scaledHeight = (image.height * scale.scaleY).roundToInt()
// Large image will cause freeze
if (image.width > 4320 || image.height > 4320) return@clipRect
copyBackgroundToAppBar(graphicsLayerSize, backgroundGraphicsLayer) {
val quality = if (appPlatform.isAndroid) FilterQuality.High else FilterQuality.Low
drawRect(background)
when (imageType) {
is WallpaperType.Preset -> drawImage(image)
is WallpaperType.Image -> when (val scaleType = imageType.scaleType ?: WallpaperScaleType.FILL) {
WallpaperScaleType.REPEAT -> drawImage(image)
WallpaperScaleType.FILL, WallpaperScaleType.FIT -> {
clipRect {
val scale = scaleType.contentScale.computeScaleFactor(Size(image.width.toFloat(), image.height.toFloat()), Size(size.width, size.height))
val scaledWidth = (image.width * scale.scaleX).roundToInt()
val scaledHeight = (image.height * scale.scaleY).roundToInt()
// Large image will cause freeze
if (image.width > 4320 || image.height > 4320) return@clipRect
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
if (scaleType == WallpaperScaleType.FIT) {
if (scaledWidth < size.width) {
// has black lines at left and right sides
var x = (size.width - scaledWidth) / 2
while (x > 0) {
drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x -= scaledWidth
}
x = size.width - (size.width - scaledWidth) / 2
while (x < size.width) {
drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x += scaledWidth
}
} else {
// has black lines at top and bottom sides
var y = (size.height - scaledHeight) / 2
while (y > 0) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y -= scaledHeight
}
y = size.height - (size.height - scaledHeight) / 2
while (y < size.height) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y += scaledHeight
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
if (scaleType == WallpaperScaleType.FIT) {
if (scaledWidth < size.width) {
// has black lines at left and right sides
var x = (size.width - scaledWidth) / 2
while (x > 0) {
drawImage(image, dstOffset = IntOffset(x = (x - scaledWidth).roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x -= scaledWidth
}
x = size.width - (size.width - scaledWidth) / 2
while (x < size.width) {
drawImage(image, dstOffset = IntOffset(x = x.roundToInt(), y = ((size.height - scaledHeight) / 2).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
x += scaledWidth
}
} else {
// has black lines at top and bottom sides
var y = (size.height - scaledHeight) / 2
while (y > 0) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = (y - scaledHeight).roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y -= scaledHeight
}
y = size.height - (size.height - scaledHeight) / 2
while (y < size.height) {
drawImage(image, dstOffset = IntOffset(x = ((size.width - scaledWidth) / 2).roundToInt(), y = y.roundToInt()), dstSize = IntSize(scaledWidth, scaledHeight), filterQuality = quality)
y += scaledHeight
}
}
}
}
drawRect(tint)
}
drawRect(tint)
}
is WallpaperType.Empty -> {}
}
is WallpaperType.Empty -> {}
}
}
}
@@ -19,6 +19,8 @@ fun ChooseAttachmentView(attachmentOption: MutableState<AttachmentOption?>, hide
Box(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.imePadding()
.wrapContentHeight()
.onFocusChanged { focusState ->
if (!focusState.hasFocus) hide()
@@ -1,181 +0,0 @@
package chat.simplex.common.views.helpers
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.geometry.Offset
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.DevicePill
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, arrangement: Arrangement.Vertical = Arrangement.Top, closeBarTitle: String? = null, barPaddingValues: PaddingValues = PaddingValues(horizontal = AppBarHorizontalPadding), endButtons: @Composable RowScope.() -> Unit = {}) {
var rowModifier = Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
if (!closeBarTitle.isNullOrEmpty()) {
rowModifier = rowModifier.background(themeBackgroundMix)
}
val handler = LocalAppBarHandler.current
val connection = LocalAppBarHandler.current?.connection
val title = remember(handler?.title?.value) { handler?.title ?: mutableStateOf("") }
Column(
verticalArrangement = arrangement,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
.drawWithCache {
val backgroundColor = if (appPlatform.isDesktop && connection != null) themeBackgroundMix.copy(alpha = topTitleAlpha(connection)) else Color.Transparent
onDrawBehind {
if (appPlatform.isDesktop) {
drawRect(backgroundColor)
}
}
}
) {
Row(
modifier = Modifier.padding(barPaddingValues),
content = {
Row(
rowModifier,
verticalAlignment = Alignment.CenterVertically
) {
if (showClose) {
NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
} else {
Spacer(Modifier)
}
if (!closeBarTitle.isNullOrEmpty()) {
Row(
Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
closeBarTitle,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
}
} else if (title.value.isNotEmpty() && connection != null) {
Row(
Modifier
.padding(start = if (showClose) 0.dp else DEFAULT_PADDING_HALF)
.weight(1f) // hides the title if something wants full width (eg, search field in chat profiles screen)
.graphicsLayer {
alpha = topTitleAlpha((connection))
}
.padding(start = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
title.value,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
} else {
Spacer(Modifier.weight(1f))
}
Row {
endButtons()
}
}
}
)
if (closeBarTitle.isNullOrEmpty() && title.value.isNotEmpty() && connection != null) {
Divider(
Modifier
.graphicsLayer {
alpha = topTitleAlpha(connection)
}
)
}
}
}
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
val handler = LocalAppBarHandler.current
val connection = handler?.connection
LaunchedEffect(title) {
handler?.title?.value = title
}
val theme = CurrentColors.collectAsState()
val titleColor = MaterialTheme.appColors.title
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
Brush.linearGradient(listOf(titleColor.darker(0.2f), titleColor.lighter(0.35f)), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
else // color is not updated when changing themes if I pass null here
Brush.linearGradient(listOf(titleColor, titleColor), Offset(0f, Float.POSITIVE_INFINITY), Offset(Float.POSITIVE_INFINITY, 0f))
Column {
Text(
title,
Modifier
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, top = DEFAULT_PADDING_HALF, end = if (withPadding) DEFAULT_PADDING else 0.dp,)
.graphicsLayer {
alpha = bottomTitleAlpha(connection)
},
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1.copy(brush = brush),
color = MaterialTheme.colors.primaryVariant,
textAlign = TextAlign.Start
)
if (hostDevice != null) {
Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer {
alpha = bottomTitleAlpha(connection)
}) {
HostDeviceTitle(hostDevice)
}
}
Spacer(Modifier.height(bottomPadding))
}
}
private fun topTitleAlpha(connection: CollapsingAppBarNestedScrollConnection) =
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, 1f)
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
@Composable
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
DevicePill(
active = true,
onClick = {},
actionButtonVisible = false,
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
text = hostDevice.second
)
}
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)*/
@Composable
fun PreviewCloseSheetBar() {
SimpleXTheme {
CloseSheetBar(close = {})
}
}
@@ -3,15 +3,67 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.layer.GraphicsLayer
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.rememberGraphicsLayer
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.IntSize
import chat.simplex.common.model.ChatController.appPrefs
val LocalAppBarHandler: ProvidableCompositionLocal<AppBarHandler?> = staticCompositionLocalOf { null }
@Composable
fun rememberAppBarHandler(key1: Any? = null, key2: Any? = null, keyboardCoversBar: Boolean = true): AppBarHandler {
val graphicsLayer = rememberGraphicsLayer()
val backgroundGraphicsLayer = rememberGraphicsLayer()
return remember(key1, key2) { AppBarHandler(graphicsLayer, backgroundGraphicsLayer, keyboardCoversBar) }
}
@Composable
fun adjustAppBarHandler(handler: AppBarHandler): AppBarHandler {
val graphicsLayer = rememberGraphicsLayer()
val backgroundGraphicsLayer = rememberGraphicsLayer()
if (handler.graphicsLayer == null || handler.graphicsLayer?.isReleased == true || handler.backgroundGraphicsLayer?.isReleased == true) {
handler.graphicsLayer = graphicsLayer
handler.backgroundGraphicsLayer = backgroundGraphicsLayer
}
return handler
}
fun Modifier.copyViewToAppBar(blurRadius: Int, graphicsLayer: GraphicsLayer?): Modifier {
return if (blurRadius > 0 && graphicsLayer != null) {
this.drawWithContent {
graphicsLayer.record {
this@drawWithContent.drawContent()
}
drawLayer(graphicsLayer)
}
} else this
}
fun DrawScope.copyBackgroundToAppBar(graphicsLayerSize: MutableState<IntSize>?, backgroundGraphicsLayer: GraphicsLayer?, scope: DrawScope.() -> Unit) {
val blurRadius = appPrefs.appearanceBarsBlurRadius.get()
if (blurRadius > 0 && graphicsLayerSize != null && backgroundGraphicsLayer != null) {
graphicsLayerSize.value = backgroundGraphicsLayer.size
backgroundGraphicsLayer.record {
scope()
}
drawLayer(backgroundGraphicsLayer)
} else {
scope()
}
}
@Stable
class AppBarHandler(
var graphicsLayer: GraphicsLayer?,
var backgroundGraphicsLayer: GraphicsLayer?,
val keyboardCoversBar: Boolean = true,
listState: LazyListState = LazyListState(0, 0),
scrollState: ScrollState = ScrollState(initial = 0)
) {
@@ -24,6 +76,8 @@ class AppBarHandler(
val connection = CollapsingAppBarNestedScrollConnection()
val backgroundGraphicsLayerSize: MutableState<IntSize> = mutableStateOf(IntSize.Zero)
companion object {
var appBarMaxHeightPx: Int = 0
}
@@ -3,7 +3,6 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.ZeroCornerSize
import androidx.compose.foundation.text.*
import androidx.compose.material.*
@@ -22,13 +21,11 @@ import androidx.compose.ui.text.input.*
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.database.PassphraseStrength
import chat.simplex.common.views.database.validKey
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DefaultBasicTextField(
modifier: Modifier,
@@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp
@Composable
fun DefaultDropdownMenu(
showMenu: MutableState<Boolean>,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
dropdownMenuItems: (@Composable () -> Unit)?
) {
@@ -23,7 +24,7 @@ fun DefaultDropdownMenu(
DropdownMenu(
expanded = showMenu.value,
onDismissRequest = { showMenu.value = false },
modifier = Modifier
modifier = modifier
.widthIn(min = 250.dp)
.background(MaterialTheme.colors.surface)
.padding(vertical = 4.dp),
@@ -3,44 +3,120 @@ package chat.simplex.common.views.helpers
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.CenteredRowLayout
import chat.simplex.res.MR
import kotlin.math.absoluteValue
@Composable
fun DefaultTopAppBar(
fun DefaultAppBar(
navigationButton: (@Composable RowScope.() -> Unit)? = null,
title: (@Composable () -> Unit)?,
title: (@Composable () -> Unit)? = null,
fixedTitleText: String? = null,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
onSearchValueChanged: (String) -> Unit,
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
onTop: Boolean,
showSearch: Boolean = false,
searchAlwaysVisible: Boolean = false,
onSearchValueChanged: (String) -> Unit = {},
buttons: @Composable RowScope.() -> Unit = {},
) {
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
val modifier = if (!showSearch) {
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
} else Modifier
} else Modifier.imePadding()
TopAppBar(
modifier = modifier,
title = {
if (!showSearch) {
title?.invoke()
} else {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = false, onValueChange = onSearchValueChanged)
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
val prefAlpha = remember { appPrefs.inAppBarsAlpha.state }
val handler = LocalAppBarHandler.current
val connection = LocalAppBarHandler.current?.connection
val titleText = remember(handler?.title?.value, fixedTitleText) {
if (fixedTitleText != null) {
mutableStateOf(fixedTitleText)
} else {
handler?.title ?: mutableStateOf("")
}
}
val keyboardInset = WindowInsets.ime
Box(modifier) {
val density = LocalDensity.current
val blurRadius = remember { appPrefs.appearanceBarsBlurRadius.state }
Box(Modifier
.matchParentSize()
.blurredBackgroundModifier(keyboardInset, handler, blurRadius, prefAlpha, handler?.keyboardCoversBar == true, onTop, density)
.drawWithCache {
// store it as a variable, don't put it inside if without holding it here. Compiler don't see it changes otherwise
val alpha = prefAlpha.value
val backgroundColor = if (title != null || fixedTitleText != null || connection == null || !onTop) {
themeBackgroundMix.copy(alpha)
} else {
themeBackgroundMix.copy(topTitleAlpha(false, connection))
}
onDrawBehind {
drawRect(backgroundColor)
}
}
},
backgroundColor = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f),
navigationIcon = navigationButton,
buttons = if (!showSearch) buttons else emptyList(),
centered = !showSearch,
)
Box(
Modifier
.fillMaxWidth()
.then(if (!onTop) Modifier.navigationBarsPadding() else Modifier)
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
) {
AppBar(
title = {
if (showSearch) {
SearchTextField(Modifier.fillMaxWidth(), alwaysVisible = searchAlwaysVisible, reducedCloseButtonPadding = 12.dp, onValueChange = onSearchValueChanged)
} else if (title != null) {
title()
} else if (titleText.value.isNotEmpty() && connection != null) {
Row(
Modifier
.graphicsLayer {
alpha = if (fixedTitleText != null) 1f else topTitleAlpha(true, connection)
}
) {
Text(
titleText.value,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
},
navigationIcon = navigationButton,
buttons = if (!showSearch) buttons else {{}},
centered = !showSearch && (title != null || !onTop),
onTop = onTop,
)
AppBarDivider(onTop, title != null || fixedTitleText != null, connection)
}
}
}
@Composable
fun CallAppBar(
title: @Composable () -> Unit,
onBack: () -> Unit
) {
AppBar(
title,
navigationIcon = { NavigationButtonBack(tintColor = Color(0xFFFFFFD8), onButtonClicked = onBack) },
centered = false,
onTop = true
)
}
@@ -83,58 +159,107 @@ fun NavigationButtonMenu(onButtonClicked: () -> Unit) {
}
@Composable
private fun TopAppBar(
private fun BoxScope.AppBarDivider(onTop: Boolean, fixedAlpha: Boolean, connection: CollapsingAppBarNestedScrollConnection?) {
if (connection != null) {
Divider(
Modifier
.align(if (onTop) Alignment.BottomStart else Alignment.TopStart)
.graphicsLayer {
alpha = if (!onTop || fixedAlpha) 1f else topTitleAlpha(false, connection, 1f)
}
)
} else {
Divider(Modifier.align(if (onTop) Alignment.BottomStart else Alignment.TopStart))
}
}
@Composable
private fun AppBar(
title: @Composable () -> Unit,
modifier: Modifier = Modifier,
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
buttons: List<@Composable RowScope.() -> Unit> = emptyList(),
backgroundColor: Color = MaterialTheme.colors.primarySurface,
buttons: @Composable RowScope.() -> Unit = {},
centered: Boolean,
onTop: Boolean,
) {
Box(
modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
.background(backgroundColor)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
val adjustedModifier = modifier
.then(if (onTop) Modifier.statusBarsPadding() else Modifier)
.height(AppBarHeight * fontSizeSqrtMultiplier)
.fillMaxWidth()
.padding(horizontal = AppBarHorizontalPadding)
if (centered) {
AppBarCenterAligned(adjustedModifier, title, navigationIcon, buttons)
} else {
AppBarStartAligned(adjustedModifier, title, navigationIcon, buttons)
}
}
@Composable
private fun AppBarStartAligned(
modifier: Modifier,
title: @Composable () -> Unit,
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
buttons: @Composable RowScope.() -> Unit
) {
Row(
modifier,
verticalAlignment = Alignment.CenterVertically
) {
if (navigationIcon != null) {
Row(
Modifier
.fillMaxHeight()
.width(TitleInsetWithIcon - AppBarHorizontalPadding),
verticalAlignment = Alignment.CenterVertically,
content = navigationIcon
)
navigationIcon()
Spacer(Modifier.width(AppBarHorizontalPadding))
} else {
Spacer(Modifier.width(DEFAULT_PADDING))
}
Row(Modifier
.weight(1f)
.padding(end = DEFAULT_PADDING_HALF)
) {
title()
}
Row(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
buttons.forEach { it() }
}
val startPadding = if (navigationIcon != null) TitleInsetWithIcon else TitleInsetWithoutIcon
val endPadding = (buttons.size * 50f).dp
Box(
Modifier
.fillMaxWidth()
.padding(
start = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else startPadding,
end = if (centered) kotlin.math.max(startPadding.value, endPadding.value).dp else endPadding
),
contentAlignment = Alignment.Center
) {
title()
buttons()
}
}
}
@Composable
private fun AppBarCenterAligned(
modifier: Modifier,
title: @Composable () -> Unit,
navigationIcon: @Composable (RowScope.() -> Unit)? = null,
buttons: @Composable RowScope.() -> Unit,
) {
CenteredRowLayout(modifier) {
if (navigationIcon != null) {
Row(
Modifier.padding(end = AppBarHorizontalPadding),
verticalAlignment = Alignment.CenterVertically,
content = navigationIcon
)
} else {
Spacer(Modifier)
}
Row(
Modifier.padding(end = DEFAULT_PADDING_HALF)
) {
title()
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
buttons()
}
}
}
private fun topTitleAlpha(text: Boolean, connection: CollapsingAppBarNestedScrollConnection, alpha: Float = appPrefs.inAppBarsAlpha.get()) =
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, if (text) 1f else alpha)
val AppBarHeight = 56.dp
val AppBarHorizontalPadding = 4.dp
val BottomAppBarHeight = 60.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
val AppBarHorizontalPadding = 2.dp
@@ -6,7 +6,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -107,6 +106,7 @@ fun <T> ExposedDropDownSettingWithIcon(
expanded.value = !expanded.value && enabled.value
}
) {
val ripple = remember { ripple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)) }
Box(
Modifier
.background(background, CircleShape)
@@ -115,7 +115,7 @@ fun <T> ExposedDropDownSettingWithIcon(
onClick = {},
role = Role.Button,
interactionSource = remember { MutableInteractionSource() },
indication = rememberRipple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)),
indication = ripple,
enabled = enabled.value
),
contentAlignment = Alignment.Center
@@ -6,12 +6,14 @@ 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 chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.StatusBarBackground
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
@@ -21,24 +23,40 @@ import kotlin.math.sqrt
fun ModalView(
close: () -> Unit,
showClose: Boolean = true,
showAppBar: Boolean = true,
enableClose: Boolean = true,
background: Color = MaterialTheme.colors.background,
background: Color = Color.Unspecified,
modifier: Modifier = Modifier,
closeOnTop: Boolean = true,
showSearch: Boolean = false,
searchAlwaysVisible: Boolean = false,
onSearchValueChanged: (String) -> Unit = {},
endButtons: @Composable RowScope.() -> Unit = {},
content: @Composable () -> Unit,
content: @Composable BoxScope.() -> Unit,
) {
if (showClose) {
if (showClose && showAppBar) {
BackHandler(enabled = enableClose, onBack = close)
}
val oneHandUI = remember { appPrefs.oneHandUI.state }
Surface(Modifier.fillMaxSize(), contentColor = LocalContentColor.current) {
Column(if (background != MaterialTheme.colors.background) Modifier.background(background) else Modifier.themedBackground()) {
if (closeOnTop) {
CloseSheetBar(if (enableClose) close else null, showClose, endButtons = endButtons)
}
Box(if (background != Color.Unspecified) Modifier.background(background) else Modifier.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer)) {
Box(modifier = modifier) {
content()
}
if (showAppBar) {
if (oneHandUI.value) {
StatusBarBackground()
}
Box(Modifier.align(if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart)) {
DefaultAppBar(
navigationButton = if (showClose) {{ NavigationButtonBack(onButtonClicked = if (enableClose) close else null) }} else null,
onTop = !oneHandUI.value,
showSearch = showSearch,
searchAlwaysVisible = searchAlwaysVisible,
onSearchValueChanged = onSearchValueChanged,
buttons = endButtons
)
}
}
}
}
}
@@ -47,7 +65,7 @@ enum class ModalPlacement {
START, CENTER, END, FULLSCREEN
}
class ModalData() {
class ModalData(val keyboardCoversBar: Boolean = true) {
private val state = mutableMapOf<String, MutableState<Any?>>()
fun <T> stateGetOrPut (key: String, default: () -> T): MutableState<T> =
state.getOrPut(key) { mutableStateOf(default() as Any) } as MutableState<T>
@@ -55,7 +73,7 @@ class ModalData() {
fun <T> stateGetOrPutNullable (key: String, default: () -> T?): MutableState<T?> =
state.getOrPut(key) { mutableStateOf(default() as Any?) } as MutableState<T?>
val appBarHandler = AppBarHandler()
val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar)
}
class ModalManager(private val placement: ModalPlacement? = null) {
@@ -69,23 +87,21 @@ class ModalManager(private val placement: ModalPlacement? = null) {
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
fun showModal(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
val data = ModalData()
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal { close ->
ModalView(close, showClose = showClose, closeOnTop = closeOnTop, endButtons = endButtons, content = { data.content() })
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() })
}
}
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, closeOnTop: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
val data = ModalData()
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
showCustomModal { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, closeOnTop = closeOnTop, content = { data.content(close) })
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) })
}
}
fun showCustomModal(animated: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showCustomModal")
val data = ModalData()
val data = ModalData(keyboardCoversBar = keyboardCoversBar)
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
// This is useful when invoking close() and ShowCustomModal one after another without delay. Otherwise, screen will hold prev view
if (toRemove.isNotEmpty()) {
@@ -146,9 +162,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// Without animation
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
modalViews.lastOrNull()?.let {
CompositionLocalProvider(
LocalAppBarHandler provides it.second.appBarHandler
) {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
}
}
@@ -164,9 +178,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
}
) {
modalViews.getOrNull(it - 1)?.let {
CompositionLocalProvider(
LocalAppBarHandler provides it.second.appBarHandler
) {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
}
}

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