Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c2c689ca91 | |||
| 56922c6323 | |||
| 91823eec95 | |||
| 8547fab700 | |||
| 5c078f91fc | |||
| ec444e7ce1 | |||
| e69d94896d | |||
| a134b3cb2f | |||
| ab24369333 | |||
| 026a8022e0 | |||
| 592b8d49d0 | |||
| af7ebe1587 | |||
| dc1106afad | |||
| cc9b4f3bb3 | |||
| 533d0e40ac | |||
| d9ad755474 | |||
| 15ca662805 | |||
| 2a8df6ace1 | |||
| bbe614aef7 | |||
| ab034e626f | |||
| fc83bc692a | |||
| d20d444e6e | |||
| fc0879ebb7 | |||
| 63ffcbc4e7 | |||
| 01edff3ca3 | |||
| cd9aa120bc | |||
| f048ddb922 | |||
| 0e39a62ab1 | |||
| 53f0fe9ca4 | |||
| 95c1d8d798 | |||
| 86dce0921e | |||
| 4a39b481b1 | |||
| e21d49db18 | |||
| 73673ab073 | |||
| 65c7ecbddf | |||
| ff065d7720 | |||
| 07165039a8 | |||
| 67472b6285 | |||
| 240aa6a2b1 | |||
| 9199fbffd5 | |||
| fcde05e9a4 | |||
| cabf8aba16 | |||
| de594aac88 | |||
| 6e5eb697a2 | |||
| 91f3335503 | |||
| 2663e6330a | |||
| f28a5b6e92 | |||
| e2e6935e5b | |||
| 769ef25c31 | |||
| 2f730d54e9 | |||
| fe0013c4a9 | |||
| 5261886b31 | |||
| 93ab3076d4 | |||
| 3b88ddbd4f | |||
| 54ff95f350 | |||
| d6dc35738e | |||
| 5b3aba9db2 | |||
| d59fdb1e09 | |||
| 4526afe7e9 | |||
| c67302a5bb | |||
| 3685c85743 | |||
| 0f301adc57 | |||
| 1f226dda64 | |||
| b4ec4df687 | |||
| f154a0bcb4 | |||
| ce5d113ff6 | |||
| a35b20f54c | |||
| 981cbb8bf9 | |||
| d5507f2fa3 | |||
| 55d180466a | |||
| 8a906485d1 | |||
| e79fa136a4 | |||
| 560b521673 | |||
| c849f5356d | |||
| 33e12e35a0 | |||
| 8a70bad9af |
@@ -72,7 +72,7 @@ You must:
|
||||
|
||||
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
|
||||
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
|
||||
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D)
|
||||
|
||||
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
|
||||
|
||||
@@ -83,7 +83,7 @@ There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=s
|
||||
|
||||
There are groups in other languages, that we have the apps interface translated into. These groups are for testing, and asking questions to other SimpleX Chat users:
|
||||
|
||||
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FkIEl7OQzcp-J6aDmjdlQbRJwqkcZE7XR%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAR16PCu02MobRmKAsjzhDWMZcWP9hS8l5AUZi-Gs8z18%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22puYPMCQt11yPUvgmI5jCiw%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FaJ8O1O8A8GbeoaHTo_V8dcefaCl7ouPb%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA034qWTA3sWcTsi6aWhNf9BA34vKVCFaEBdP2R66z6Ao%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22wiZ1v_wNjLPlT-nCSB-bRA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FvIHQDxTor53nwnWWTy5cHNwQQAdWN5Hw%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAPdgK1eBnETmgiqEQufbUkydKBJafoRx4iRrtrC2NAGc%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%221FyUryBPza-1ZFFE80Ekbg%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FXZyt3hJmWsycpN7Dqve_wbrAqb6myk1R%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAMFVIoytozTEa_QXOgoZFq_oe0IwZBYKvW50trSFXzXo%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22xz05ngjA3pNIxLZ32a8Vxg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=1-2&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2F0weR-ZgDUl7ruOtI_8TZwEsnJP6UiImA%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAq4PSThO9Fvb5ydF48wB0yNbpzCbuQJCW3vZ9BGUfcxk%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22e-iceLA0SctC62eARgYDWg%3D%3D%22%7D) (Italian-speaking).
|
||||
[\#SimpleX-DE](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FmfiivxDKWFuowXrQOp11jsY8TuP__rBL%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAiz3pKNwvKudckFYMUfgoT0s96B0jfZ7ALHAu7rtE9HQ%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22jZeJpXGrRXQJU_-MSJ_v2A%3D%3D%22%7D) (German-speaking), [\#SimpleX-ES](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FJ5ES83pJimY2BRklS8fvy_iQwIU37xra%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEA0F0STP6UqN_12_k2cjjTrIjFgBGeWhOAmbY1qlk3pnM%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22VmUU0fqmYdCRmVCyvStvHA%3D%3D%22%7D) (Spanish-speaking), [\#SimpleX-FR](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FxCHBE_6PBRMqNEpm4UQDHXb9cz-mN7dd%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAetqlcM7zTCRw-iatnwCrvpJSto7lq5Yv6AsBMWv7GSM%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22foO5Xw4hhjOa_x7zET7otw%3D%3D%22%7D) (French-speaking), [\#SimpleX-RU](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FVXQTB0J2lLjYkgjWByhl6-1qmb5fgZHh%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAI6JaEWezfSwvcoTEkk6au-gkjrXR2ew2OqZYMYBvayk%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22ORH9OEe8Duissh-hslfeVg%3D%3D%22%7D) (Russian-speaking), [\#SimpleX-IT](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FqpHu0psOUdYfc11yQCzSyq5JhijrBzZT%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEACZ_7fbwlM45wl6cGif8cY47oPQ_AMdP0ATqOYLA6zHY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%229uRQRTir3ealdcSfB0zsrw%3D%3D%22%7D) (Italian-speaking).
|
||||
|
||||
You can join either by opening these links in the app or by opening them in a desktop browser and scanning the QR code.
|
||||
|
||||
|
||||
@@ -29,12 +29,12 @@ struct ContentView: View {
|
||||
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
|
||||
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
|
||||
@AppStorage(DEFAULT_NOTIFICATION_ALERT_SHOWN) private var notificationAlertShown = false
|
||||
@State private var showSettings = false
|
||||
@State private var showWhatsNew = false
|
||||
@State private var showChooseLAMode = false
|
||||
@State private var showSetPasscode = false
|
||||
@State private var waitingForOrPassedAuth = true
|
||||
@State private var chatListActionSheet: ChatListActionSheet? = nil
|
||||
@State private var chatListUserPickerSheet: UserPickerSheet? = nil
|
||||
|
||||
private let callTopPadding: CGFloat = 40
|
||||
|
||||
@@ -86,7 +86,7 @@ struct ContentView: View {
|
||||
callView(call)
|
||||
}
|
||||
|
||||
if !showSettings, let la = chatModel.laRequest {
|
||||
if chatListUserPickerSheet == nil, let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
.onDisappear {
|
||||
// this flag is separate from accessAuthenticated to show initializationView while we wait for authentication
|
||||
@@ -109,9 +109,6 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
|
||||
.sheet(isPresented: $showSettings) {
|
||||
SettingsView(showSettings: $showSettings)
|
||||
}
|
||||
.confirmationDialog("SimpleX Lock mode", isPresented: $showChooseLAMode, titleVisibility: .visible) {
|
||||
Button("System authentication") { initialEnableLA() }
|
||||
Button("Passcode entry") { showSetPasscode = true }
|
||||
@@ -253,7 +250,7 @@ struct ContentView: View {
|
||||
|
||||
private func mainView() -> some View {
|
||||
ZStack(alignment: .top) {
|
||||
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
|
||||
ChatListView(activeUserPickerSheet: $chatListUserPickerSheet).privacySensitive(protectScreen)
|
||||
.onAppear {
|
||||
requestNtfAuthorization()
|
||||
// Local Authentication notice is to be shown on next start after onboarding is complete
|
||||
|
||||
@@ -197,7 +197,7 @@ class ThemeManager {
|
||||
var themeIds = currentThemeIdsDefault.get()
|
||||
themeIds[nonSystemThemeName] = prevValue.themeId
|
||||
currentThemeIdsDefault.set(themeIds)
|
||||
applyTheme(nonSystemThemeName)
|
||||
applyTheme(currentThemeDefault.get())
|
||||
}
|
||||
|
||||
static func copyFromSameThemeOverrides(_ type: WallpaperType?, _ lowerLevelOverride: ThemeModeOverride?, _ pref: Binding<ThemeModeOverride>) -> Bool {
|
||||
|
||||
@@ -75,9 +75,14 @@ enum MetaColorMode {
|
||||
}
|
||||
}
|
||||
|
||||
var statusSpacer: Text {
|
||||
func statusSpacer(_ sent: Bool) -> Text {
|
||||
switch self {
|
||||
case .normal, .transparent: Text(Image(systemName: "circlebadge.fill")).foregroundColor(.clear)
|
||||
case .normal, .transparent:
|
||||
Text(
|
||||
sent
|
||||
? Image("checkmark.wide")
|
||||
: Image(systemName: "circlebadge.fill")
|
||||
).foregroundColor(.clear)
|
||||
case .invertedMaterial: Text(" ").kerning(13)
|
||||
}
|
||||
}
|
||||
@@ -130,10 +135,10 @@ func ciMetaText(
|
||||
colorMode.resolve(statusColor)
|
||||
}
|
||||
r = r + colored(Text(image), metaColor)
|
||||
space = Text(" ")
|
||||
} else if !meta.disappearing {
|
||||
space = colorMode.statusSpacer + Text(" ")
|
||||
r = r + colorMode.statusSpacer(meta.itemStatus.sent)
|
||||
}
|
||||
space = Text(" ")
|
||||
}
|
||||
if let enc = encrypted {
|
||||
appendSpace()
|
||||
|
||||
@@ -19,7 +19,6 @@ struct ChatItemForwardingView: View {
|
||||
@Binding var composeState: ComposeState
|
||||
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocused
|
||||
@State private var alert: SomeAlert?
|
||||
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
|
||||
|
||||
@@ -46,8 +45,6 @@ struct ChatItemForwardingView: View {
|
||||
VStack(alignment: .leading) {
|
||||
if !chatsToForwardTo.isEmpty {
|
||||
List {
|
||||
searchFieldView(text: $searchText, focussed: $searchFocused, theme.colors.onBackground, theme.colors.secondary)
|
||||
.padding(.leading, 2)
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let chats = s == "" ? chatsToForwardTo : chatsToForwardTo.filter { foundChat($0, s) }
|
||||
ForEach(chats) { chat in
|
||||
@@ -55,6 +52,7 @@ struct ChatItemForwardingView: View {
|
||||
.disabled(chatModel.deletedChats.contains(chat.chatInfo.id))
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} else {
|
||||
ZStack {
|
||||
|
||||
@@ -622,7 +622,11 @@ struct ChatView: View {
|
||||
Text(String.localizedStringWithFormat(
|
||||
NSLocalizedString("%@, %@", comment: "format for date separator in chat"),
|
||||
date.formatted(.dateTime.weekday(.abbreviated)),
|
||||
date.formatted(.dateTime.day().month(.abbreviated))
|
||||
date.formatted(
|
||||
Calendar.current.isDate(date, equalTo: .now, toGranularity: .year)
|
||||
? .dateTime.day().month(.abbreviated)
|
||||
: .dateTime.day().month(.abbreviated).year()
|
||||
)
|
||||
))
|
||||
.font(.callout)
|
||||
.fontWeight(.medium)
|
||||
|
||||
@@ -105,8 +105,10 @@ struct AddGroupMembersViewCommon: View {
|
||||
.padding(.leading, 2)
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let members = s == "" ? membersToAdd : membersToAdd.filter { $0.chatViewName.localizedLowercase.contains(s) }
|
||||
ForEach(members) { contact in
|
||||
contactCheckView(contact)
|
||||
ForEach(members + [dummyContact]) { contact in
|
||||
if contact.contactId != dummyContact.contactId {
|
||||
contactCheckView(contact)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,6 +132,14 @@ struct AddGroupMembersViewCommon: View {
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
|
||||
// Resolves keyboard losing focus bug in iOS16 and iOS17,
|
||||
// when there are no items inside `ForEach(memebers)` loop
|
||||
private let dummyContact: Contact = {
|
||||
var dummy = Contact.sampleData
|
||||
dummy.contactId = -1
|
||||
return dummy
|
||||
}()
|
||||
|
||||
private func inviteMembersButton() -> some View {
|
||||
Button {
|
||||
inviteMembers()
|
||||
|
||||
@@ -10,7 +10,7 @@ import SwiftUI
|
||||
|
||||
struct ChatHelp: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@Binding var showSettings: Bool
|
||||
let dismissSettingsSheet: DismissAction
|
||||
|
||||
var body: some View {
|
||||
ScrollView { chatHelp() }
|
||||
@@ -23,7 +23,7 @@ struct ChatHelp: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text("To ask any questions and to receive updates:")
|
||||
Button("connect to SimpleX Chat developers.") {
|
||||
showSettings = false
|
||||
dismissSettingsSheet()
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
@@ -61,8 +61,9 @@ struct ChatHelp: View {
|
||||
}
|
||||
|
||||
struct ChatHelp_Previews: PreviewProvider {
|
||||
@Environment(\.dismiss) static var mockDismiss
|
||||
|
||||
static var previews: some View {
|
||||
@State var showSettings = false
|
||||
return ChatHelp(showSettings: $showSettings)
|
||||
ChatHelp(dismissSettingsSheet: mockDismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,19 +18,77 @@ enum UserPickerSheet: Identifiable {
|
||||
case settings
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var navigationTitle: LocalizedStringKey {
|
||||
switch self {
|
||||
case .address: "SimpleX address"
|
||||
case .chatPreferences: "Your preferences"
|
||||
case .chatProfiles: "Your chat profiles"
|
||||
case .currentProfile: "Your current profile"
|
||||
case .useFromDesktop: "Connect to desktop"
|
||||
case .settings: "Your settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UserPickerSheetView: View {
|
||||
let sheet: UserPickerSheet
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@State private var loaded = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
if loaded, let currentUser = chatModel.currentUser {
|
||||
switch sheet {
|
||||
case .address:
|
||||
UserAddressView(shareViaProfile: currentUser.addressShared)
|
||||
case .chatPreferences:
|
||||
PreferencesView(
|
||||
profile: currentUser.profile,
|
||||
preferences: currentUser.fullPreferences,
|
||||
currentPreferences: currentUser.fullPreferences
|
||||
)
|
||||
case .chatProfiles:
|
||||
UserProfilesView()
|
||||
case .currentProfile:
|
||||
UserProfile()
|
||||
case .useFromDesktop:
|
||||
ConnectDesktopView()
|
||||
case .settings:
|
||||
SettingsView()
|
||||
}
|
||||
}
|
||||
Color.clear // Required for list background to be rendered during loading
|
||||
}
|
||||
.navigationTitle(sheet.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
.overlay {
|
||||
if let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
withAnimation(
|
||||
.easeOut(duration: 0.1),
|
||||
{ loaded = true }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatListView: View {
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var showSettings: Bool
|
||||
@Binding var activeUserPickerSheet: UserPickerSheet?
|
||||
@State private var searchMode = false
|
||||
@FocusState private var searchFocussed
|
||||
@State private var searchText = ""
|
||||
@State private var searchShowingSimplexLink = false
|
||||
@State private var searchChatFilteredBySimplexLink: String? = nil
|
||||
@State private var scrollToSearchBar = false
|
||||
@State private var activeUserPickerSheet: UserPickerSheet? = nil
|
||||
@State private var userPickerShown: Bool = false
|
||||
|
||||
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
|
||||
@@ -58,43 +116,20 @@ struct ChatListView: View {
|
||||
destination: chatView
|
||||
) { chatListView }
|
||||
}
|
||||
.sheet(isPresented: $userPickerShown) {
|
||||
UserPicker(activeSheet: $activeUserPickerSheet)
|
||||
.sheet(item: $activeUserPickerSheet) { sheet in
|
||||
if let currentUser = chatModel.currentUser {
|
||||
switch sheet {
|
||||
case .address:
|
||||
NavigationView {
|
||||
UserAddressView(shareViaProfile: currentUser.addressShared)
|
||||
.navigationTitle("SimpleX address")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
case .chatProfiles:
|
||||
NavigationView {
|
||||
UserProfilesView()
|
||||
}
|
||||
case .currentProfile:
|
||||
NavigationView {
|
||||
UserProfile()
|
||||
.navigationTitle("Your current profile")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
case .chatPreferences:
|
||||
NavigationView {
|
||||
PreferencesView(profile: currentUser.profile, preferences: currentUser.fullPreferences, currentPreferences: currentUser.fullPreferences)
|
||||
.navigationTitle("Your preferences")
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
case .useFromDesktop:
|
||||
ConnectDesktopView(viaSettings: false)
|
||||
case .settings:
|
||||
SettingsView(showSettings: $showSettings)
|
||||
.navigationBarTitleDisplayMode(.large)
|
||||
}
|
||||
}
|
||||
.modifier(
|
||||
Sheet(isPresented: $userPickerShown) {
|
||||
UserPicker(userPickerShown: $userPickerShown, activeSheet: $activeUserPickerSheet)
|
||||
}
|
||||
)
|
||||
.sheet(item: $activeUserPickerSheet) {
|
||||
UserPickerSheetView(sheet: $0)
|
||||
}
|
||||
.onChange(of: activeUserPickerSheet) {
|
||||
if $0 != nil {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
||||
userPickerShown = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,6 +579,8 @@ func chatStoppedIcon() -> some View {
|
||||
}
|
||||
|
||||
struct ChatListView_Previews: PreviewProvider {
|
||||
@State static var userPickerSheet: UserPickerSheet? = .none
|
||||
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.updateChats([
|
||||
@@ -562,9 +599,9 @@ struct ChatListView_Previews: PreviewProvider {
|
||||
|
||||
])
|
||||
return Group {
|
||||
ChatListView(showSettings: Binding.constant(false))
|
||||
ChatListView(activeUserPickerSheet: $userPickerSheet)
|
||||
.environmentObject(chatModel)
|
||||
ChatListView(showSettings: Binding.constant(false))
|
||||
ChatListView(activeUserPickerSheet: $userPickerSheet)
|
||||
.environmentObject(ChatModel())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +35,16 @@ struct ChatPreviewView: View {
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
|
||||
let chatTs = if let cItem {
|
||||
cItem.meta.itemTs
|
||||
} else {
|
||||
chat.chatInfo.chatTs
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .top) {
|
||||
chatPreviewTitle()
|
||||
Spacer()
|
||||
(cItem?.timestampText ?? formatTimestampText(chat.chatInfo.chatTs))
|
||||
(formatTimestampText(chatTs))
|
||||
.font(.subheadline)
|
||||
.frame(minWidth: 60, alignment: .trailing)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
|
||||
@@ -12,202 +12,156 @@ struct UserPicker: View {
|
||||
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
|
||||
@Environment(\.scenePhase) private var scenePhase: ScenePhase
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@Environment(\.dismiss) private var dismiss: DismissAction
|
||||
@Binding var userPickerShown: Bool
|
||||
@Binding var activeSheet: UserPickerSheet?
|
||||
@State private var currentUser: Int64?
|
||||
@State private var switchingProfile = false
|
||||
@State private var frameWidth: CGFloat = 0
|
||||
@State private var resetScroll = ResetScrollAction()
|
||||
|
||||
// Inset grouped list dimensions
|
||||
private let imageSize: CGFloat = 44
|
||||
private let rowPadding: CGFloat = 16
|
||||
private let rowVerticalPadding: CGFloat = 11
|
||||
private let sectionSpacing: CGFloat = 35
|
||||
private var sectionHorizontalPadding: CGFloat { frameWidth > 375 ? 20 : 16 }
|
||||
private let sectionShape = RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
|
||||
var body: some View {
|
||||
if #available(iOS 16.0, *) {
|
||||
let v = viewBody.presentationDetents([.height(420)])
|
||||
if #available(iOS 16.4, *) {
|
||||
v.scrollBounceBehavior(.basedOnSize)
|
||||
} else {
|
||||
v
|
||||
let otherUsers: [UserInfo] = m.users
|
||||
.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
|
||||
.sorted(using: KeyPathComparator<UserInfo>(\.user.activeOrder, order: .reverse))
|
||||
let sectionWidth = max(frameWidth - sectionHorizontalPadding * 2, 0)
|
||||
let currentUserWidth = max(frameWidth - sectionHorizontalPadding - rowPadding * 2 - 14 - imageSize, 0)
|
||||
VStack(spacing: sectionSpacing) {
|
||||
if let user = m.currentUser {
|
||||
StickyScrollView(resetScroll: $resetScroll) {
|
||||
HStack(spacing: rowPadding) {
|
||||
HStack {
|
||||
ProfileImage(imageStr: user.image, size: imageSize, color: Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.padding(.trailing, 6)
|
||||
profileName(user).lineLimit(1)
|
||||
}
|
||||
.padding(rowPadding)
|
||||
.frame(width: otherUsers.isEmpty ? sectionWidth : currentUserWidth, alignment: .leading)
|
||||
.modifier(ListRow { activeSheet = .currentProfile })
|
||||
.clipShape(sectionShape)
|
||||
ForEach(otherUsers) { u in
|
||||
userView(u, size: imageSize)
|
||||
.frame(maxWidth: sectionWidth * 0.618)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, sectionHorizontalPadding)
|
||||
}
|
||||
.frame(height: 2 * rowPadding + imageSize)
|
||||
.padding(.top, sectionSpacing)
|
||||
.overlay(DetermineWidth())
|
||||
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
|
||||
}
|
||||
} else {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
|
||||
private var viewBody: some View {
|
||||
let otherUsers = m.users.filter { u in !u.user.hidden && u.user.userId != m.currentUser?.userId }
|
||||
return List {
|
||||
Section(header: Text("You").foregroundColor(theme.colors.secondary)) {
|
||||
if let user = m.currentUser {
|
||||
openSheetOnTap(label: {
|
||||
ZStack {
|
||||
let v = ProfilePreview(profileOf: user)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.leading, -8)
|
||||
if #available(iOS 16.0, *) {
|
||||
v
|
||||
VStack(spacing: 0) {
|
||||
openSheetOnTap("qrcode", title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", sheet: .address)
|
||||
openSheetOnTap("switch.2", title: "Chat preferences", sheet: .chatPreferences)
|
||||
openSheetOnTap("person.crop.rectangle.stack", title: "Your chat profiles", sheet: .chatProfiles)
|
||||
openSheetOnTap("desktopcomputer", title: "Use from desktop", sheet: .useFromDesktop)
|
||||
ZStack(alignment: .trailing) {
|
||||
openSheetOnTap("gearshape", title: "Settings", sheet: .settings, showDivider: false)
|
||||
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: 20, maxHeight: .infinity)
|
||||
.padding(.horizontal, rowPadding)
|
||||
.background(Color(.systemBackground).opacity(0.01))
|
||||
.onTapGesture {
|
||||
if (colorScheme == .light) {
|
||||
ThemeManager.applyTheme(systemDarkThemeDefault.get())
|
||||
} else {
|
||||
v.padding(.vertical, 4)
|
||||
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
activeSheet = .currentProfile
|
||||
}
|
||||
|
||||
openSheetOnTap(title: m.userAddress == nil ? "Create SimpleX address" : "Your SimpleX address", icon: "qrcode") {
|
||||
activeSheet = .address
|
||||
}
|
||||
|
||||
openSheetOnTap(title: "Chat preferences", icon: "switch.2") {
|
||||
activeSheet = .chatPreferences
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if otherUsers.isEmpty {
|
||||
openSheetOnTap(title: "Your chat profiles", icon: "person.crop.rectangle.stack") {
|
||||
activeSheet = .chatProfiles
|
||||
}
|
||||
} else {
|
||||
let v = userPickerRow(otherUsers, size: 44)
|
||||
.padding(.leading, -11)
|
||||
if #available(iOS 16.0, *) {
|
||||
v
|
||||
} else {
|
||||
v.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
openSheetOnTap(title: "Use from desktop", icon: "desktopcomputer") {
|
||||
activeSheet = .useFromDesktop
|
||||
}
|
||||
|
||||
ZStack(alignment: .trailing) {
|
||||
openSheetOnTap(title: "Settings", icon: "gearshape") {
|
||||
activeSheet = .settings
|
||||
}
|
||||
Label {} icon: {
|
||||
Image(systemName: colorScheme == .light ? "sun.max" : "moon.fill")
|
||||
.resizable()
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.frame(maxWidth: 20, maxHeight: 20)
|
||||
}
|
||||
.onTapGesture {
|
||||
if (colorScheme == .light) {
|
||||
ThemeManager.applyTheme(systemDarkThemeDefault.get())
|
||||
} else {
|
||||
ThemeManager.applyTheme(DefaultTheme.LIGHT.themeName)
|
||||
.onLongPressGesture {
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||
}
|
||||
}
|
||||
.onLongPressGesture {
|
||||
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
.clipShape(sectionShape)
|
||||
.padding(.horizontal, sectionHorizontalPadding)
|
||||
.padding(.bottom, sectionSpacing)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.onAppear {
|
||||
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
|
||||
if case .active = scenePhase {
|
||||
currentUser = m.currentUser?.userId
|
||||
Task {
|
||||
do {
|
||||
let users = try await listUsersAsync()
|
||||
await MainActor.run { m.users = users }
|
||||
await MainActor.run {
|
||||
m.users = users
|
||||
currentUser = m.currentUser?.userId
|
||||
}
|
||||
} catch {
|
||||
logger.error("Error loading users \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onChange(of: userPickerShown) {
|
||||
if !$0 { resetScroll() }
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.disabled(switchingProfile)
|
||||
}
|
||||
|
||||
private func userPickerRow(_ users: [UserInfo], size: CGFloat) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
let s = ScrollView(.horizontal) {
|
||||
HStack(spacing: 27) {
|
||||
ForEach(users) { u in
|
||||
if !u.user.hidden && u.user.userId != m.currentUser?.userId {
|
||||
userView(u, size: size)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.leading, 4)
|
||||
.padding(.trailing, 22)
|
||||
}
|
||||
ZStack(alignment: .trailing) {
|
||||
if #available(iOS 16.0, *) {
|
||||
s.scrollIndicators(.hidden)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
LinearGradient(
|
||||
colors: [.clear, .black],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: size, height: size + 3)
|
||||
.blendMode(.destinationOut)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.compositingGroup()
|
||||
.padding(.top, -3) // to fit unread badge
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.padding(.trailing, 4)
|
||||
.onTapGesture {
|
||||
activeSheet = .chatProfiles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func userView(_ u: UserInfo, size: CGFloat) -> some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
.padding([.top, .trailing], 3)
|
||||
if (u.unreadCount > 0) {
|
||||
unreadBadge(u)
|
||||
HStack {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
ProfileImage(imageStr: u.user.image, size: size, color: Color(uiColor: .tertiarySystemGroupedBackground))
|
||||
if (u.unreadCount > 0) {
|
||||
unreadBadge(u).offset(x: 4, y: -4)
|
||||
}
|
||||
}
|
||||
.padding(.trailing, 6)
|
||||
Text(u.user.displayName).font(.title2).lineLimit(1)
|
||||
}
|
||||
.frame(width: size)
|
||||
.onTapGesture {
|
||||
.padding(rowPadding)
|
||||
.modifier(ListRow {
|
||||
switchingProfile = true
|
||||
Task {
|
||||
do {
|
||||
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
|
||||
await MainActor.run {
|
||||
switchingProfile = false
|
||||
dismiss()
|
||||
userPickerShown = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
switchingProfile = false
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title: "Error switching profile!",
|
||||
message: "Error: \(responseError(error))"
|
||||
showAlert(
|
||||
NSLocalizedString("Error switching profile!", comment: "alertTitle"),
|
||||
message: String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "alert message"), responseError(error))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.clipShape(sectionShape)
|
||||
}
|
||||
|
||||
private func openSheetOnTap(title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
|
||||
openSheetOnTap(label: {
|
||||
ZStack(alignment: .leading) {
|
||||
Image(systemName: icon).frame(maxWidth: 24, maxHeight: 24, alignment: .center)
|
||||
.symbolRenderingMode(.monochrome)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text(title)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.leading, 36)
|
||||
|
||||
private func openSheetOnTap(_ icon: String, title: LocalizedStringKey, sheet: UserPickerSheet, showDivider: Bool = true) -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
settingsRow(icon, color: theme.colors.secondary) {
|
||||
Text(title).foregroundColor(.primary)
|
||||
}
|
||||
}, action: action)
|
||||
}
|
||||
|
||||
private func openSheetOnTap<V: View>(label: () -> V, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action, label: label)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, rowPadding)
|
||||
.padding(.vertical, rowVerticalPadding)
|
||||
.modifier(ListRow { activeSheet = sheet })
|
||||
if showDivider {
|
||||
Divider().padding(.leading, 52)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func unreadBadge(_ u: UserInfo) -> some View {
|
||||
@@ -222,6 +176,92 @@ struct UserPicker: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ListRow: ViewModifier {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@State private var touchDown = false
|
||||
let action: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
elevatedSecondarySystemGroupedBackground
|
||||
Color(.systemGray4).opacity(touchDown ? 1 : 0)
|
||||
content
|
||||
TouchOverlay(touchDown: $touchDown, action: action)
|
||||
}
|
||||
}
|
||||
|
||||
var elevatedSecondarySystemGroupedBackground: Color {
|
||||
switch colorScheme {
|
||||
case .dark: Color(0xFF2C2C2E)
|
||||
default: Color(0xFFFFFFFF)
|
||||
}
|
||||
}
|
||||
|
||||
struct TouchOverlay: UIViewRepresentable {
|
||||
@Binding var touchDown: Bool
|
||||
let action: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> TouchView {
|
||||
let touchView = TouchView()
|
||||
let gesture = UILongPressGestureRecognizer(
|
||||
target: touchView,
|
||||
action: #selector(touchView.longPress(gesture:))
|
||||
)
|
||||
gesture.delegate = touchView
|
||||
gesture.minimumPressDuration = 0
|
||||
touchView.addGestureRecognizer(gesture)
|
||||
return touchView
|
||||
}
|
||||
|
||||
func updateUIView(_ touchView: TouchView, context: Context) {
|
||||
touchView.representer = self
|
||||
}
|
||||
|
||||
class TouchView: UIView, UIGestureRecognizerDelegate {
|
||||
var representer: TouchOverlay?
|
||||
private var startLocation: CGPoint?
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
@objc
|
||||
func longPress(gesture: UILongPressGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
startLocation = gesture.location(in: nil)
|
||||
task = Task {
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 200_000000)
|
||||
await MainActor.run { representer?.touchDown = true }
|
||||
} catch { }
|
||||
}
|
||||
case .ended:
|
||||
if hitTest(gesture.location(in: self), with: nil) == self {
|
||||
representer?.action()
|
||||
}
|
||||
task?.cancel()
|
||||
representer?.touchDown = false
|
||||
case .changed:
|
||||
if let startLocation {
|
||||
let location = gesture.location(in: nil)
|
||||
let dx = location.x - startLocation.x
|
||||
let dy = location.y - startLocation.y
|
||||
if sqrt(pow(dx, 2) + pow(dy, 2)) > 10 { gesture.state = .failed }
|
||||
}
|
||||
case .cancelled, .failed:
|
||||
task?.cancel()
|
||||
representer?.touchDown = false
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
func gestureRecognizer(
|
||||
_: UIGestureRecognizer,
|
||||
shouldRecognizeSimultaneouslyWith: UIGestureRecognizer
|
||||
) -> Bool { true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct UserPicker_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
@State var activeSheet: UserPickerSheet?
|
||||
@@ -229,6 +269,7 @@ struct UserPicker_Previews: PreviewProvider {
|
||||
let m = ChatModel()
|
||||
m.users = [UserInfo.sampleData, UserInfo.sampleData]
|
||||
return UserPicker(
|
||||
userPickerShown: .constant(true),
|
||||
activeSheet: $activeSheet
|
||||
)
|
||||
.environmentObject(m)
|
||||
|
||||
@@ -44,7 +44,7 @@ enum DatabaseAlert: Identifiable {
|
||||
struct DatabaseView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var showSettings: Bool
|
||||
let dismissSettingsSheet: DismissAction
|
||||
@State private var runChat = false
|
||||
@State private var alert: DatabaseAlert? = nil
|
||||
@State private var showFileImporter = false
|
||||
@@ -439,7 +439,7 @@ struct DatabaseView: View {
|
||||
|
||||
private func startChat() {
|
||||
if m.chatDbChanged {
|
||||
showSettings = false
|
||||
dismissSettingsSheet()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
|
||||
resetChatCtrl()
|
||||
do {
|
||||
@@ -533,7 +533,9 @@ func deleteChatAsync() async throws {
|
||||
}
|
||||
|
||||
struct DatabaseView_Previews: PreviewProvider {
|
||||
@Environment(\.dismiss) static var mockDismiss
|
||||
|
||||
static var previews: some View {
|
||||
DatabaseView(showSettings: Binding.constant(false), chatItemTTL: .none)
|
||||
DatabaseView(dismissSettingsSheet: mockDismiss, chatItemTTL: .none)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//
|
||||
// SwiftUISheet.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by user on 23/09/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
private let sheetAnimationDuration: Double = 0.35
|
||||
|
||||
// Refrence: https://easings.net/
|
||||
private let easeOutCubic = UICubicTimingParameters(
|
||||
controlPoint1: CGPoint(x: 0.215, y: 0.61),
|
||||
controlPoint2: CGPoint(x: 0.355, y: 1)
|
||||
)
|
||||
|
||||
struct Sheet<SheetContent: View>: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
@ViewBuilder let sheetContent: () -> SheetContent
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
content
|
||||
SheetRepresentable(isPresented: $isPresented, content: sheetContent())
|
||||
.allowsHitTesting(isPresented)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SheetRepresentable<Content: View>: UIViewControllerRepresentable {
|
||||
@Binding var isPresented: Bool
|
||||
let content: Content
|
||||
|
||||
func makeUIViewController(context: Context) -> Controller<Content> {
|
||||
Controller(content: content, representer: self)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ sheetController: Controller<Content>, context: Context) {
|
||||
sheetController.animate(isPresented: isPresented)
|
||||
}
|
||||
|
||||
class Controller<C: View>: UIViewController {
|
||||
let hostingController: UIHostingController<C>
|
||||
private let animator = UIViewPropertyAnimator(
|
||||
duration: sheetAnimationDuration,
|
||||
timingParameters: easeOutCubic
|
||||
)
|
||||
private let representer: SheetRepresentable<C>
|
||||
private var retainedFraction: CGFloat = 0
|
||||
private var sheetHeight: Double { hostingController.view.frame.height }
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(content: C, representer: SheetRepresentable<C>) {
|
||||
self.representer = representer
|
||||
self.hostingController = UIHostingController(rootView: content)
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) missing") }
|
||||
|
||||
deinit {
|
||||
animator.stopAnimation(true)
|
||||
animator.finishAnimation(at: .current)
|
||||
}
|
||||
|
||||
func animate(isPresented: Bool) {
|
||||
let alreadyAnimating = animator.isRunning && isPresented != animator.isReversed
|
||||
let sheetFullyDismissed = animator.fractionComplete == (animator.isReversed ? 1 : 0)
|
||||
let sheetFullyPresented = animator.fractionComplete == (animator.isReversed ? 0 : 1)
|
||||
|
||||
if !isPresented && sheetFullyDismissed ||
|
||||
isPresented && sheetFullyPresented ||
|
||||
alreadyAnimating {
|
||||
return
|
||||
}
|
||||
|
||||
animator.pauseAnimation()
|
||||
animator.isReversed = !isPresented
|
||||
animator.continueAnimation(
|
||||
withTimingParameters: isPresented
|
||||
? easeOutCubic
|
||||
: UICubicTimingParameters(animationCurve: .easeIn),
|
||||
durationFactor: 1 - animator.fractionComplete
|
||||
)
|
||||
handleVisibility()
|
||||
}
|
||||
|
||||
func handleVisibility() {
|
||||
if animator.isReversed {
|
||||
task = Task {
|
||||
do {
|
||||
let sleepDuration = UInt64(sheetAnimationDuration * Double(NSEC_PER_SEC))
|
||||
try await Task.sleep(nanoseconds: sleepDuration)
|
||||
view.isHidden = true
|
||||
} catch { }
|
||||
}
|
||||
} else {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
view.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
view.isHidden = true
|
||||
view.backgroundColor = .clear
|
||||
view.addGestureRecognizer(
|
||||
UITapGestureRecognizer(target: self, action: #selector(tap(gesture:)))
|
||||
)
|
||||
addChild(hostingController)
|
||||
hostingController.didMove(toParent: self)
|
||||
if let sheet = hostingController.view {
|
||||
sheet.isHidden = true
|
||||
sheet.clipsToBounds = true
|
||||
sheet.layer.cornerRadius = 10
|
||||
sheet.layer.maskedCorners = [.layerMaxXMinYCorner, .layerMinXMinYCorner]
|
||||
sheet.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(pan(gesture:))))
|
||||
sheet.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(sheet)
|
||||
NSLayoutConstraint.activate([
|
||||
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
// Ensures animations are only setup once
|
||||
// on some iOS version `viewDidAppear` can get called on each state change.
|
||||
if hostingController.view.isHidden {
|
||||
hostingController.view.transform = CGAffineTransform(translationX: 0, y: self.sheetHeight)
|
||||
hostingController.view.isHidden = false
|
||||
animator.pausesOnCompletion = true
|
||||
animator.addAnimations {
|
||||
self.hostingController.view.transform = .identity
|
||||
self.view.backgroundColor = UIColor {
|
||||
switch $0.userInterfaceStyle {
|
||||
case .dark: .black.withAlphaComponent(0.290)
|
||||
default: .black.withAlphaComponent(0.121)
|
||||
}
|
||||
}
|
||||
}
|
||||
animator.startAnimation()
|
||||
animator.pauseAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func pan(gesture: UIPanGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .began:
|
||||
animator.isReversed = false
|
||||
animator.pauseAnimation()
|
||||
retainedFraction = animator.fractionComplete
|
||||
case .changed:
|
||||
animator.fractionComplete = retainedFraction - gesture.translation(in: view).y / sheetHeight
|
||||
case .ended, .cancelled:
|
||||
let velocity = gesture.velocity(in: view).y
|
||||
animator.isReversed = (velocity - (animator.fractionComplete - 0.5) * 100).sign == .plus
|
||||
let defaultVelocity = sheetHeight / sheetAnimationDuration
|
||||
let fractionRemaining = 1 - animator.fractionComplete
|
||||
let durationFactor = min(max(fractionRemaining / (abs(velocity) / defaultVelocity), 0.5), 1)
|
||||
animator.continueAnimation(withTimingParameters: nil, durationFactor: durationFactor * fractionRemaining)
|
||||
handleVisibility()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + sheetAnimationDuration) {
|
||||
self.representer.isPresented = !self.animator.isReversed
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
@objc
|
||||
func tap(gesture: UITapGestureRecognizer) {
|
||||
switch gesture.state {
|
||||
case .ended:
|
||||
if gesture.location(in: view).y < view.frame.height - sheetHeight {
|
||||
representer.isPresented = false
|
||||
}
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// StickyScrollView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by user on 20/09/2024.
|
||||
// Copyright © 2024 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct StickyScrollView<Content: View>: UIViewRepresentable {
|
||||
@Binding var resetScroll: ResetScrollAction
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
func makeUIView(context: Context) -> UIScrollView {
|
||||
let hc = context.coordinator.hostingController
|
||||
hc.view.backgroundColor = .clear
|
||||
let sv = UIScrollView()
|
||||
sv.showsHorizontalScrollIndicator = false
|
||||
sv.addSubview(hc.view)
|
||||
sv.delegate = context.coordinator
|
||||
DispatchQueue.main.async {
|
||||
resetScroll = ResetScrollAction { sv.setContentOffset(.zero, animated: false) }
|
||||
}
|
||||
return sv
|
||||
}
|
||||
|
||||
func updateUIView(_ scrollView: UIScrollView, context: Context) {
|
||||
let hc = context.coordinator.hostingController
|
||||
hc.rootView = content()
|
||||
hc.view.frame.size = hc.view.intrinsicContentSize
|
||||
scrollView.contentSize = hc.view.intrinsicContentSize
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(content: content())
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, UIScrollViewDelegate {
|
||||
let hostingController: UIHostingController<Content>
|
||||
|
||||
init(content: Content) {
|
||||
self.hostingController = UIHostingController(rootView: content)
|
||||
}
|
||||
|
||||
func scrollViewWillEndDragging(
|
||||
_ scrollView: UIScrollView,
|
||||
withVelocity velocity: CGPoint,
|
||||
targetContentOffset: UnsafeMutablePointer<CGPoint>
|
||||
) {
|
||||
if targetContentOffset.pointee.x < 32 {
|
||||
targetContentOffset.pointee.x = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ResetScrollAction {
|
||||
var action = { }
|
||||
func callAsFunction() { action() }
|
||||
}
|
||||
@@ -241,7 +241,6 @@ private struct InviteView: View {
|
||||
@Binding var choosingProfile: Bool
|
||||
|
||||
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
|
||||
@State private var showSettings: Bool = false
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
@@ -693,6 +692,7 @@ struct ScannerInView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundColor(Color.clear)
|
||||
switch cameraAuthorizationStatus {
|
||||
case .authorized, nil: EmptyView()
|
||||
case .restricted: Text("Camera not available")
|
||||
case .denied: Label("Enable camera access", systemImage: "camera")
|
||||
default: Label("Tap to scan", systemImage: "qrcode")
|
||||
@@ -712,21 +712,26 @@ struct ScannerInView: View {
|
||||
.disabled(cameraAuthorizationStatus == .restricted)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
.task {
|
||||
let status = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
cameraAuthorizationStatus = status
|
||||
if showQRCodeScanner {
|
||||
switch status {
|
||||
case .notDetermined: askCameraAuthorization()
|
||||
case .notDetermined: await askCameraAuthorizationAsync()
|
||||
case .restricted: showQRCodeScanner = false
|
||||
case .denied: showQRCodeScanner = false
|
||||
case .authorized: ()
|
||||
@unknown default: askCameraAuthorization()
|
||||
@unknown default: await askCameraAuthorizationAsync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func askCameraAuthorizationAsync() async {
|
||||
await AVCaptureDevice.requestAccess(for: .video)
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
}
|
||||
|
||||
func askCameraAuthorization(_ cb: (() -> Void)? = nil) {
|
||||
AVCaptureDevice.requestAccess(for: .video) { allowed in
|
||||
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
||||
|
||||
@@ -49,34 +49,34 @@ struct QRCode: View {
|
||||
ZStack {
|
||||
if let image = image {
|
||||
qrCodeImage(image)
|
||||
}
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
if withLogo {
|
||||
let w = geo.size.width
|
||||
Image("icon-light")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w * 0.16, height: w * 0.16)
|
||||
.frame(width: w * 0.165, height: w * 0.165)
|
||||
.background(.white)
|
||||
.clipShape(Circle())
|
||||
GeometryReader { geo in
|
||||
ZStack {
|
||||
if withLogo {
|
||||
let w = geo.size.width
|
||||
Image("icon-light")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: w * 0.16, height: w * 0.16)
|
||||
.frame(width: w * 0.165, height: w * 0.165)
|
||||
.background(.white)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
makeScreenshotFunc = {
|
||||
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
|
||||
onShare?()
|
||||
.onAppear {
|
||||
makeScreenshotFunc = {
|
||||
let size = CGSizeMake(1024 / UIScreen.main.scale, 1024 / UIScreen.main.scale)
|
||||
showShareSheet(items: [makeScreenshot(geo.frame(in: .local).origin, size)])
|
||||
onShare?()
|
||||
}
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
}
|
||||
.frame(width: geo.size.width, height: geo.size.height)
|
||||
} else {
|
||||
Color.clear.aspectRatio(1, contentMode: .fit)
|
||||
}
|
||||
}
|
||||
.onTapGesture(perform: makeScreenshotFunc)
|
||||
.onAppear {
|
||||
image = image ?? generateImage(uri, tintColor: tintColor)
|
||||
}
|
||||
.task { image = await generateImage(uri, tintColor: tintColor) }
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,7 @@ private func qrCodeImage(_ image: UIImage) -> some View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
private func generateImage(_ uri: String, tintColor: UIColor) -> UIImage? {
|
||||
private func generateImage(_ uri: String, tintColor: UIColor) async -> UIImage? {
|
||||
let context = CIContext()
|
||||
let filter = CIFilter.qrCodeGenerator()
|
||||
filter.message = Data(uri.utf8)
|
||||
|
||||
@@ -14,7 +14,6 @@ struct ConnectDesktopView: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
var viaSettings = false
|
||||
@AppStorage(DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS) private var deviceName = UIDevice.current.name
|
||||
@AppStorage(DEFAULT_CONFIRM_REMOTE_SESSIONS) private var confirmRemoteSessions = false
|
||||
@AppStorage(DEFAULT_CONNECT_REMOTE_VIA_MULTICAST) private var connectRemoteViaMulticast = true
|
||||
@@ -57,16 +56,6 @@ struct ConnectDesktopView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if viaSettings {
|
||||
viewBody
|
||||
} else {
|
||||
NavigationView {
|
||||
viewBody
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var viewBody: some View {
|
||||
Group {
|
||||
let discovery = m.remoteCtrlSession?.discovery
|
||||
if discovery == true || (discovery == nil && !showConnectScreen) {
|
||||
|
||||
@@ -808,7 +808,7 @@ struct ThemeDestinationPicker: View {
|
||||
@Binding var customizeThemeIsOpen: Bool
|
||||
|
||||
var body: some View {
|
||||
let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
|
||||
let values = [(nil, NSLocalizedString("All profiles", comment: "profile dropdown"))] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
|
||||
|
||||
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
|
||||
Picker("Apply to", selection: $themeUserDest) {
|
||||
|
||||
@@ -257,23 +257,18 @@ let networkProxyDefault: CodableDefault<NetworkProxy> = CodableDefault(defaults:
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var sceneDelegate: SceneDelegate
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Binding var showSettings: Bool
|
||||
@State private var showProgress: Bool = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
NavigationView {
|
||||
settingsView()
|
||||
}
|
||||
settingsView()
|
||||
if showProgress {
|
||||
progressView()
|
||||
}
|
||||
if let la = chatModel.laRequest {
|
||||
LocalAuthView(authRequest: la)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,7 +342,7 @@ struct SettingsView: View {
|
||||
Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
|
||||
if let user = user {
|
||||
NavigationLink {
|
||||
ChatHelp(showSettings: $showSettings)
|
||||
ChatHelp(dismissSettingsSheet: dismiss)
|
||||
.navigationTitle("Welcome \(user.displayName)!")
|
||||
.modifier(ThemedBackground())
|
||||
.frame(maxHeight: .infinity, alignment: .top)
|
||||
@@ -372,7 +367,7 @@ struct SettingsView: View {
|
||||
}
|
||||
settingsRow("number", color: theme.colors.secondary) {
|
||||
Button("Send questions and ideas") {
|
||||
showSettings = false
|
||||
dismiss()
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.open(simplexTeamURL)
|
||||
}
|
||||
@@ -429,7 +424,7 @@ struct SettingsView: View {
|
||||
|
||||
private func chatDatabaseRow() -> some View {
|
||||
NavigationLink {
|
||||
DatabaseView(showSettings: $showSettings, chatItemTTL: chatModel.chatItemTTL)
|
||||
DatabaseView(dismissSettingsSheet: dismiss, chatItemTTL: chatModel.chatItemTTL)
|
||||
.navigationTitle("Your chat database")
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
} label: {
|
||||
@@ -507,27 +502,25 @@ struct ProfilePreview: View {
|
||||
HStack {
|
||||
ProfileImage(imageStr: profileOf.image, size: 44, color: color)
|
||||
.padding(.trailing, 6)
|
||||
profileName().lineLimit(1)
|
||||
profileName(profileOf).lineLimit(1)
|
||||
}
|
||||
}
|
||||
|
||||
private func profileName() -> Text {
|
||||
var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
|
||||
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
|
||||
t = t + Text(" (" + profileOf.fullName + ")")
|
||||
}
|
||||
|
||||
func profileName(_ profileOf: NamedChat) -> Text {
|
||||
var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
|
||||
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
|
||||
t = t + Text(" (" + profileOf.fullName + ")")
|
||||
// .font(.callout)
|
||||
}
|
||||
return t
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
struct SettingsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let chatModel = ChatModel()
|
||||
chatModel.currentUser = User.sampleData
|
||||
@State var showSettings = false
|
||||
|
||||
return SettingsView(showSettings: $showSettings)
|
||||
return SettingsView()
|
||||
.environmentObject(chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,24 +204,26 @@
|
||||
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 */; };
|
||||
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 */; };
|
||||
CEE723B12C3BD3D70009AE93 /* SimpleX SE.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = CEE723A72C3BD3D70009AE93 /* SimpleX SE.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
CEE723F02C3D25C70009AE93 /* ShareView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723EF2C3D25C70009AE93 /* ShareView.swift */; };
|
||||
CEE723F22C3D25ED0009AE93 /* ShareModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEE723F12C3D25ED0009AE93 /* ShareModel.swift */; };
|
||||
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; };
|
||||
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */; };
|
||||
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
|
||||
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
|
||||
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547729AF89AF0022400A /* StoreKit.framework */; };
|
||||
D741547A29AF90B00022400A /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547929AF90B00022400A /* PushKit.framework */; };
|
||||
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
|
||||
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
|
||||
E51B923B2CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E51B92362CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU.a */; };
|
||||
E51B923C2CAB2B8800C212F2 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E51B92372CAB2B8800C212F2 /* libgmp.a */; };
|
||||
E51B923D2CAB2B8800C212F2 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E51B92382CAB2B8800C212F2 /* libffi.a */; };
|
||||
E51B923E2CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E51B92392CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU-ghc9.6.3.a */; };
|
||||
E51B923F2CAB2B8800C212F2 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E51B923A2CAB2B8800C212F2 /* libgmpxx.a */; };
|
||||
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; };
|
||||
E55128F12C9DA948001D165C /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128EC2C9DA948001D165C /* libffi.a */; };
|
||||
E55128F22C9DA948001D165C /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128ED2C9DA948001D165C /* libgmpxx.a */; };
|
||||
E55128F32C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128EE2C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx-ghc9.6.3.a */; };
|
||||
E55128F42C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128EF2C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx.a */; };
|
||||
E55128F52C9DA948001D165C /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E55128F02C9DA948001D165C /* libgmp.a */; };
|
||||
E5DCF8DB2C56FAC1007928CC /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
|
||||
E5DCF9712C590272007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF96F2C590272007928CC /* Localizable.strings */; };
|
||||
E5DCF9842C5902CE007928CC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = E5DCF9822C5902CE007928CC /* Localizable.strings */; };
|
||||
@@ -543,6 +545,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>"; };
|
||||
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; };
|
||||
CEE723A92C3BD3D70009AE93 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
|
||||
@@ -551,16 +554,17 @@
|
||||
CEE723EF2C3D25C70009AE93 /* ShareView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareView.swift; sourceTree = "<group>"; };
|
||||
CEE723F12C3D25ED0009AE93 /* ShareModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareModel.swift; sourceTree = "<group>"; };
|
||||
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = "<group>"; };
|
||||
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetRepresentable.swift; sourceTree = "<group>"; };
|
||||
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
|
||||
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
|
||||
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
|
||||
E51B92362CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU.a"; sourceTree = "<group>"; };
|
||||
E51B92372CAB2B8800C212F2 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
E51B92382CAB2B8800C212F2 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
E51B92392CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
E51B923A2CAB2B8800C212F2 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; };
|
||||
E55128EC2C9DA948001D165C /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
E55128ED2C9DA948001D165C /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
E55128EE2C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
E55128EF2C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx.a"; sourceTree = "<group>"; };
|
||||
E55128F02C9DA948001D165C /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
E5DCF9702C590272007928CC /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5DCF9722C590274007928CC /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
E5DCF9732C590275007928CC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
@@ -651,14 +655,14 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
E55128F32C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx-ghc9.6.3.a in Frameworks */,
|
||||
E55128F42C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx.a in Frameworks */,
|
||||
E55128F52C9DA948001D165C /* libgmp.a in Frameworks */,
|
||||
E51B923B2CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU.a in Frameworks */,
|
||||
E51B923D2CAB2B8800C212F2 /* libffi.a in Frameworks */,
|
||||
E51B923C2CAB2B8800C212F2 /* libgmp.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
E55128F22C9DA948001D165C /* libgmpxx.a in Frameworks */,
|
||||
E51B923F2CAB2B8800C212F2 /* libgmpxx.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
E55128F12C9DA948001D165C /* libffi.a in Frameworks */,
|
||||
E51B923E2CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU-ghc9.6.3.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -735,11 +739,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E55128EC2C9DA948001D165C /* libffi.a */,
|
||||
E55128F02C9DA948001D165C /* libgmp.a */,
|
||||
E55128ED2C9DA948001D165C /* libgmpxx.a */,
|
||||
E55128EE2C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx-ghc9.6.3.a */,
|
||||
E55128EF2C9DA948001D165C /* libHSsimplex-chat-6.1.0.3-7yIa9Uiui2A43fFRiuUJXx.a */,
|
||||
E51B92382CAB2B8800C212F2 /* libffi.a */,
|
||||
E51B92372CAB2B8800C212F2 /* libgmp.a */,
|
||||
E51B923A2CAB2B8800C212F2 /* libgmpxx.a */,
|
||||
E51B92392CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU-ghc9.6.3.a */,
|
||||
E51B92362CAB2B8800C212F2 /* libHSsimplex-chat-6.1.0.5-HWShuJBYoppEOt1hLB8GPU.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -796,6 +800,8 @@
|
||||
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */,
|
||||
CE7548092C622630009579B7 /* SwipeLabel.swift */,
|
||||
CE176F1F2C87014C00145DBC /* InvertedForegroundStyle.swift */,
|
||||
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */,
|
||||
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@@ -1446,6 +1452,7 @@
|
||||
5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */,
|
||||
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */,
|
||||
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
|
||||
CEFB2EDF2CA1BCC7004B1ECE /* SheetRepresentable.swift in Sources */,
|
||||
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
|
||||
6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */,
|
||||
5CF937232B2503D000E1D781 /* NSESubscriber.swift in Sources */,
|
||||
@@ -1496,6 +1503,7 @@
|
||||
5C93293F2928E0FD0090FFF9 /* AudioRecPlay.swift in Sources */,
|
||||
5C029EA82837DBB3004A9677 /* CICallItemView.swift in Sources */,
|
||||
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */,
|
||||
CEDB245B2C9CD71800FBC5F6 /* StickyScrollView.swift in Sources */,
|
||||
5C9CC7A928C532AB00BEF955 /* DatabaseErrorView.swift in Sources */,
|
||||
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */,
|
||||
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */,
|
||||
@@ -1891,7 +1899,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1940,7 +1948,7 @@
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -1981,7 +1989,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
@@ -2001,7 +2009,7 @@
|
||||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
@@ -2026,7 +2034,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = s;
|
||||
@@ -2063,7 +2071,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_CODE_COVERAGE = NO;
|
||||
@@ -2100,7 +2108,7 @@
|
||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -2151,7 +2159,7 @@
|
||||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -2202,7 +2210,7 @@
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
@@ -2236,7 +2244,7 @@
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 238;
|
||||
CURRENT_PROJECT_VERSION = 241;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
|
||||
@@ -1353,6 +1353,7 @@ public struct NetCfg: Codable, Equatable {
|
||||
public var sessionMode = TransportSessionMode.user
|
||||
public var smpProxyMode: SMPProxyMode = .unknown
|
||||
public var smpProxyFallback: SMPProxyFallback = .allowProtected
|
||||
var smpWebPort = false
|
||||
public var tcpConnectTimeout: Int // microseconds
|
||||
public var tcpTimeout: Int // microseconds
|
||||
public var tcpTimeoutPerKb: Int // microseconds
|
||||
|
||||
@@ -17,6 +17,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
|
||||
public var profile: LocalProfile
|
||||
public var fullPreferences: FullPreferences
|
||||
public var activeUser: Bool
|
||||
public var activeOrder: Int64
|
||||
|
||||
public var displayName: String { get { profile.displayName } }
|
||||
public var fullName: String { get { profile.fullName } }
|
||||
@@ -49,6 +50,7 @@ public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
|
||||
profile: LocalProfile.sampleData,
|
||||
fullPreferences: FullPreferences.sampleData,
|
||||
activeUser: true,
|
||||
activeOrder: 0,
|
||||
showNtfs: true,
|
||||
sendRcptsContacts: true,
|
||||
sendRcptsSmallGroups: false
|
||||
@@ -2763,9 +2765,16 @@ public struct CITimed: Decodable, Hashable {
|
||||
|
||||
let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
|
||||
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
|
||||
let msgDateYearFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits).year(.twoDigits)
|
||||
|
||||
public func formatTimestampText(_ date: Date) -> Text {
|
||||
Text(verbatim: date.formatted(recent(date) ? msgTimeFormat : msgDateFormat))
|
||||
Text(verbatim: date.formatted(
|
||||
recent(date)
|
||||
? msgTimeFormat
|
||||
: Calendar.current.isDate(date, equalTo: .now, toGranularity: .year)
|
||||
? msgDateFormat
|
||||
: msgDateYearFormat
|
||||
))
|
||||
}
|
||||
|
||||
public func formatTimestampMeta(_ date: Date) -> String {
|
||||
@@ -2811,6 +2820,20 @@ public enum CIStatus: Decodable, Hashable {
|
||||
case .invalid: return "invalid"
|
||||
}
|
||||
}
|
||||
|
||||
public var sent: Bool {
|
||||
switch self {
|
||||
case .sndNew: true
|
||||
case .sndSent: true
|
||||
case .sndRcvd: true
|
||||
case .sndErrorAuth: true
|
||||
case .sndError: true
|
||||
case .sndWarning: true
|
||||
case .rcvNew: false
|
||||
case .rcvRead: false
|
||||
case .invalid: false
|
||||
}
|
||||
}
|
||||
|
||||
public func statusIcon(_ metaColor: Color, _ paleMetaColor: Color, _ primaryColor: Color = .accentColor) -> (Image, Color)? {
|
||||
switch self {
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.app
|
||||
|
||||
import android.app.*
|
||||
import android.content.*
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -83,7 +84,7 @@ class CallService: Service() {
|
||||
generalGetString(MR.strings.notification_preview_somebody)
|
||||
else
|
||||
call?.contact?.profile?.displayName ?: ""
|
||||
val text = generalGetString(if (call?.supportsVideo() == true) MR.strings.call_service_notification_video_call else MR.strings.call_service_notification_audio_call)
|
||||
val text = generalGetString(if (call?.hasVideo == true) MR.strings.call_service_notification_video_call else MR.strings.call_service_notification_audio_call)
|
||||
val image = call?.contact?.image
|
||||
val largeIcon = if (image == null || previewMode == NotificationPreviewMode.HIDDEN.name)
|
||||
BitmapFactory.decodeResource(resources, R.drawable.icon)
|
||||
@@ -105,7 +106,7 @@ class CallService: Service() {
|
||||
0
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT >= 30) {
|
||||
if (call.supportsVideo()) {
|
||||
if (call.hasVideo && ContextCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA
|
||||
} else {
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
|
||||
|
||||
@@ -287,13 +287,23 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
// 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
|
||||
window.statusBarColor = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
|
||||
val navBar = navBarColor.toArgb()
|
||||
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
|
||||
}
|
||||
@@ -313,11 +323,13 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
backgroundColor
|
||||
}
|
||||
}).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)
|
||||
@@ -327,8 +339,8 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
||||
if (window.statusBarColor != statusBar) {
|
||||
window.statusBarColor = statusBar
|
||||
}
|
||||
if (windowInsetController?.isAppearanceLightStatusBars != isLight) {
|
||||
windowInsetController?.isAppearanceLightStatusBars = isLight
|
||||
if (windowInsetController?.isAppearanceLightStatusBars != statusBarLight) {
|
||||
windowInsetController?.isAppearanceLightStatusBars = statusBarLight
|
||||
}
|
||||
if (window.navigationBarColor != navBar) {
|
||||
window.navigationBarColor = navBar
|
||||
|
||||
@@ -116,7 +116,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
|
||||
|
||||
private fun hasGrantedPermissions(): Boolean {
|
||||
val grantedAudio = ContextCompat.checkSelfPermission(this, android.Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED
|
||||
val grantedCamera = !callSupportsVideo() || ContextCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
val grantedCamera = !callHasVideo() || ContextCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
|
||||
return grantedAudio && grantedCamera
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
|
||||
override fun onBackPressed() {
|
||||
if (isOnLockScreenNow()) {
|
||||
super.onBackPressed()
|
||||
} else if (!hasGrantedPermissions() && !callSupportsVideo()) {
|
||||
} else if (!hasGrantedPermissions() && !callHasVideo()) {
|
||||
val call = m.activeCall.value
|
||||
if (call != null) {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
@@ -142,7 +142,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
|
||||
override fun onUserLeaveHint() {
|
||||
super.onUserLeaveHint()
|
||||
// On Android 12+ PiP is enabled automatically when a user hides the app
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callSupportsVideo() && platform.androidPictureInPictureAllowed()) {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R && callHasVideo() && platform.androidPictureInPictureAllowed()) {
|
||||
enterPictureInPictureMode()
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ class CallActivity: ComponentActivity(), ServiceConnection {
|
||||
fun getKeyguardManager(context: Context): KeyguardManager =
|
||||
context.getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||
|
||||
private fun callSupportsVideo() = m.activeCall.value?.supportsVideo() == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
|
||||
private fun callHasVideo() = m.activeCall.value?.hasVideo == true || m.activeCallInvitation.value?.callType?.media == CallMediaType.Video
|
||||
|
||||
@Composable
|
||||
fun CallActivityView() {
|
||||
@@ -212,7 +212,7 @@ fun CallActivityView() {
|
||||
.collect { collapsed ->
|
||||
when {
|
||||
collapsed -> {
|
||||
if (!platform.androidPictureInPictureAllowed() || !callSupportsVideo()) {
|
||||
if (!platform.androidPictureInPictureAllowed() || !callHasVideo()) {
|
||||
activity.moveTaskToBack(true)
|
||||
activity.startActivity(Intent(activity, MainActivity::class.java))
|
||||
} else if (!activity.isInPictureInPictureMode && activity.lifecycle.currentState == Lifecycle.State.RESUMED) {
|
||||
@@ -221,7 +221,7 @@ fun CallActivityView() {
|
||||
activity.enterPictureInPictureMode()
|
||||
}
|
||||
}
|
||||
callSupportsVideo() && !platform.androidPictureInPictureAllowed() -> {
|
||||
callHasVideo() && !platform.androidPictureInPictureAllowed() -> {
|
||||
// PiP disabled by user
|
||||
platform.androidStartCallActivity(false)
|
||||
}
|
||||
@@ -242,28 +242,43 @@ fun CallActivityView() {
|
||||
Box(Modifier.background(Color.Black)) {
|
||||
if (call != null) {
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
permissions = if (callSupportsVideo()) {
|
||||
permissions = if (callHasVideo()) {
|
||||
listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO)
|
||||
} else {
|
||||
listOf(Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
)
|
||||
if (permissionsState.allPermissionsGranted) {
|
||||
// callState == connected is needed in a situation when a peer enabled camera in audio call while a user didn't grant camera permission yet,
|
||||
// so no need to hide active call view in this case
|
||||
if (permissionsState.allPermissionsGranted || call.callState == CallState.Connected) {
|
||||
ActiveCallView()
|
||||
LaunchedEffect(Unit) {
|
||||
activity.startServiceAndBind()
|
||||
}
|
||||
} else {
|
||||
CallPermissionsView(remember { m.activeCallViewIsCollapsed }.value, callSupportsVideo()) {
|
||||
}
|
||||
if ((!permissionsState.allPermissionsGranted && call.callState != CallState.Connected) || call.wantsToEnableCamera) {
|
||||
CallPermissionsView(remember { m.activeCallViewIsCollapsed }.value, callHasVideo() || call.wantsToEnableCamera) {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
val cameraAndMicPermissions = rememberMultiplePermissionsState(permissions = listOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
|
||||
DisposableEffect(cameraAndMicPermissions.allPermissionsGranted) {
|
||||
onDispose {
|
||||
if (call.wantsToEnableCamera && cameraAndMicPermissions.allPermissionsGranted) {
|
||||
val activeCall = chatModel.activeCall.value
|
||||
if (activeCall != null && activeCall.contact.apiId == call.contact.apiId) {
|
||||
chatModel.activeCall.value = activeCall.copy(wantsToEnableCamera = false)
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Camera, enable = true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val view = LocalView.current
|
||||
if (callSupportsVideo()) {
|
||||
if (callHasVideo()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(Unit) {
|
||||
scope.launch {
|
||||
activity.setPipParams(callSupportsVideo(), viewRatio = Rational(view.width, view.height))
|
||||
activity.setPipParams(callHasVideo(), viewRatio = Rational(view.width, view.height))
|
||||
activity.trackPipAnimationHintView(view)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ class PostSCallAudioDeviceManager: CallAudioDeviceManagerInterface {
|
||||
Log.d(TAG, "Added audio devices2: ${devices.value.map { it.type }}")
|
||||
|
||||
if (devices.value.size - oldDevices.size > 0) {
|
||||
selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.supportsVideo() == true, false)
|
||||
selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.hasVideo == true, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,14 +116,14 @@ class PreSCallAudioDeviceManager: CallAudioDeviceManagerInterface {
|
||||
Log.d(TAG, "Added audio devices: ${addedDevices.map { it.type }}")
|
||||
super.onAudioDevicesAdded(addedDevices)
|
||||
devices.value = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).filter { it.hasSupportedType() }.excludeSameType().excludeEarpieceIfWired()
|
||||
selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.supportsVideo() == true, false)
|
||||
selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.hasVideo == true, false)
|
||||
}
|
||||
|
||||
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>) {
|
||||
Log.d(TAG, "Removed audio devices: ${removedDevices.map { it.type }}")
|
||||
super.onAudioDevicesRemoved(removedDevices)
|
||||
devices.value = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS).filter { it.hasSupportedType() }.excludeSameType().excludeEarpieceIfWired()
|
||||
selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.supportsVideo() == true, true)
|
||||
selectLastExternalDeviceOrDefault(chatModel.activeCall.value?.hasVideo == true, true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.annotation.SuppressLint
|
||||
import android.app.Activity
|
||||
import android.content.*
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.*
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
@@ -16,9 +17,12 @@ import android.view.ViewGroup
|
||||
import android.webkit.*
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
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
|
||||
@@ -27,12 +31,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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 androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.*
|
||||
import androidx.webkit.WebViewAssetLoader
|
||||
import androidx.webkit.WebViewClientCompat
|
||||
@@ -119,26 +124,26 @@ actual fun ActiveCallView() {
|
||||
val callRh = call.remoteHostId
|
||||
when (val r = apiMsg.resp) {
|
||||
is WCallResponse.Capabilities -> withBGApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
val callType = CallType(call.initialCallType, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) }
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
// Starting is delayed to make Android <= 11 working good with Bluetooth
|
||||
callAudioDeviceManager.start()
|
||||
} else {
|
||||
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.supportsVideo(), true)
|
||||
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true)
|
||||
}
|
||||
CallSoundsPlayer.startConnectingCallSound(scope)
|
||||
activeCallWaitDeliveryReceipt(scope)
|
||||
}
|
||||
is WCallResponse.Offer -> withBGApi {
|
||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.initialCallType, r.capabilities)
|
||||
updateActiveCall(call) { it.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) }
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||
// Starting is delayed to make Android <= 11 working good with Bluetooth
|
||||
callAudioDeviceManager.start()
|
||||
} else {
|
||||
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.supportsVideo(), true)
|
||||
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true)
|
||||
}
|
||||
}
|
||||
is WCallResponse.Answer -> withBGApi {
|
||||
@@ -162,6 +167,17 @@ actual fun ActiveCallView() {
|
||||
is WCallResponse.Connected -> {
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo) }
|
||||
}
|
||||
is WCallResponse.PeerMedia -> {
|
||||
updateActiveCall(call) {
|
||||
val sources = it.peerMediaSources
|
||||
when (r.source) {
|
||||
CallMediaSource.Mic -> it.copy(peerMediaSources = sources.copy(mic = r.enabled))
|
||||
CallMediaSource.Camera -> it.copy(peerMediaSources = sources.copy(camera = r.enabled))
|
||||
CallMediaSource.ScreenAudio -> it.copy(peerMediaSources = sources.copy(screenAudio = r.enabled))
|
||||
CallMediaSource.ScreenVideo -> it.copy(peerMediaSources = sources.copy(screenVideo = r.enabled))
|
||||
}
|
||||
}
|
||||
}
|
||||
is WCallResponse.End -> {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
@@ -174,16 +190,19 @@ actual fun ActiveCallView() {
|
||||
updateActiveCall(call) { it.copy(callState = CallState.Negotiated) }
|
||||
is WCallCommand.Media -> {
|
||||
updateActiveCall(call) {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> it.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> it.copy(audioEnabled = cmd.enable)
|
||||
val sources = it.localMediaSources
|
||||
when (cmd.source) {
|
||||
CallMediaSource.Mic -> it.copy(localMediaSources = sources.copy(mic = cmd.enable))
|
||||
CallMediaSource.Camera -> it.copy(localMediaSources = sources.copy(camera = cmd.enable))
|
||||
CallMediaSource.ScreenAudio -> it.copy(localMediaSources = sources.copy(screenAudio = cmd.enable))
|
||||
CallMediaSource.ScreenVideo -> it.copy(localMediaSources = sources.copy(screenVideo = cmd.enable))
|
||||
}
|
||||
}
|
||||
}
|
||||
is WCallCommand.Camera -> {
|
||||
updateActiveCall(call) { it.copy(localCamera = cmd.camera) }
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||
if (!call.localMediaSources.mic) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Mic, enable = false))
|
||||
}
|
||||
}
|
||||
is WCallCommand.End -> {
|
||||
@@ -200,7 +219,6 @@ actual fun ActiveCallView() {
|
||||
val showOverlay = when {
|
||||
call == null -> false
|
||||
!platform.androidPictureInPictureAllowed() -> true
|
||||
!call.supportsVideo() -> true
|
||||
!chatModel.activeCallViewIsCollapsed.value -> true
|
||||
else -> false
|
||||
}
|
||||
@@ -208,6 +226,11 @@ actual fun ActiveCallView() {
|
||||
ActiveCallOverlay(call, chatModel, callAudioDeviceManager)
|
||||
}
|
||||
}
|
||||
KeyChangeEffect(call?.hasVideo) {
|
||||
if (call != null) {
|
||||
callAudioDeviceManager.selectLastExternalDeviceOrDefault(call.hasVideo, true)
|
||||
}
|
||||
}
|
||||
val context = LocalContext.current
|
||||
DisposableEffect(Unit) {
|
||||
val activity = context as? Activity ?: return@DisposableEffect onDispose {}
|
||||
@@ -237,9 +260,15 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, callAudioDeviceM
|
||||
devices = remember { callAudioDeviceManager.devices }.value,
|
||||
currentDevice = remember { callAudioDeviceManager.currentDevice },
|
||||
dismiss = { withBGApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) },
|
||||
toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Mic, enable = !call.localMediaSources.mic)) },
|
||||
selectDevice = { callAudioDeviceManager.selectDevice(it.id) },
|
||||
toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) },
|
||||
toggleVideo = {
|
||||
if (ContextCompat.checkSelfPermission(androidAppContext, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Camera, enable = !call.localMediaSources.camera))
|
||||
} else {
|
||||
updateActiveCall(call) { it.copy(wantsToEnableCamera = true) }
|
||||
}
|
||||
},
|
||||
toggleSound = {
|
||||
val enableSpeaker = callAudioDeviceManager.currentDevice.value?.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE
|
||||
val preferredInternalDevice = callAudioDeviceManager.devices.value.firstOrNull { it.type == if (enableSpeaker) AudioDeviceInfo.TYPE_BUILTIN_SPEAKER else AudioDeviceInfo.TYPE_BUILTIN_EARPIECE }
|
||||
@@ -293,30 +322,30 @@ private fun ActiveCallOverlayLayout(
|
||||
flipCamera: () -> Unit
|
||||
) {
|
||||
Column {
|
||||
val media = call.peerMedia ?: call.localMedia
|
||||
CloseSheetBar({ chatModel.activeCallViewIsCollapsed.value = true }, true, tintColor = Color(0xFFFFFFD8)) {
|
||||
if (media == CallMediaType.Video) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
|
||||
@Composable
|
||||
fun SelectSoundDevice() {
|
||||
fun SelectSoundDevice(size: Dp) {
|
||||
if (devices.size == 2 &&
|
||||
devices.all { it.type == AudioDeviceInfo.TYPE_BUILTIN_EARPIECE || it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } ||
|
||||
currentDevice.value == null ||
|
||||
devices.none { it.id == currentDevice.value?.id }
|
||||
) {
|
||||
val isSpeaker = currentDevice.value?.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
|
||||
ToggleSoundButton(call, enabled, isSpeaker, toggleSound)
|
||||
ToggleSoundButton(enabled, isSpeaker, !call.peerMediaSources.mic, toggleSound, size = size)
|
||||
} else {
|
||||
ExposedDropDownSettingWithIcon(
|
||||
devices.map { Triple(it, it.icon, if (it.name != null) generalGetString(it.name!!) else it.productName.toString()) },
|
||||
devices.map { Triple(it, if (call.peerMediaSources.mic) it.icon else MR.images.ic_volume_off, if (it.name != null) generalGetString(it.name!!) else it.productName.toString()) },
|
||||
currentDevice,
|
||||
fontSize = 18.sp,
|
||||
iconSize = 40.dp,
|
||||
boxSize = size,
|
||||
listIconSize = 30.dp,
|
||||
iconColor = Color(0xFFFFFFD8),
|
||||
background = controlButtonsBackground(),
|
||||
minWidth = 300.dp,
|
||||
onSelected = {
|
||||
if (it != null) {
|
||||
@@ -327,29 +356,9 @@ private fun ActiveCallOverlayLayout(
|
||||
}
|
||||
}
|
||||
|
||||
when (media) {
|
||||
CallMediaType.Video -> {
|
||||
VideoCallInfoView(call)
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Row(Modifier.fillMaxWidth().padding(horizontal = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
|
||||
ToggleAudioButton(call, enabled, toggleAudio)
|
||||
SelectSoundDevice()
|
||||
IconButton(onClick = dismiss, enabled = enabled) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
if (call.videoEnabled) {
|
||||
ControlButton(call, painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera)
|
||||
ControlButton(call, painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, enabled, toggleVideo)
|
||||
} else {
|
||||
Spacer(Modifier.size(48.dp))
|
||||
ControlButton(call, painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, enabled, toggleVideo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CallMediaType.Audio -> {
|
||||
when (call.hasVideo) {
|
||||
true -> VideoCallInfoView(call)
|
||||
false -> {
|
||||
Spacer(Modifier.fillMaxHeight().weight(1f))
|
||||
Column(
|
||||
Modifier.fillMaxWidth(),
|
||||
@@ -359,23 +368,26 @@ private fun ActiveCallOverlayLayout(
|
||||
ProfileImage(size = 192.dp, image = call.contact.profile.image)
|
||||
AudioCallInfoView(call)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.CenterStart) {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
IconButton(onClick = dismiss, enabled = enabled) {
|
||||
Icon(painterResource(MR.images.ic_call_end_filled), stringResource(MR.strings.icon_descr_hang_up), tint = if (enabled) Color.Red else MaterialTheme.colors.secondary, modifier = Modifier.size(64.dp))
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(start = 32.dp)) {
|
||||
ToggleAudioButton(call, enabled, toggleAudio)
|
||||
}
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
|
||||
Box(Modifier.padding(end = 32.dp)) {
|
||||
SelectSoundDevice()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().fillMaxHeight().weight(1f), contentAlignment = Alignment.BottomCenter) {
|
||||
DisabledBackgroundCallsButton()
|
||||
}
|
||||
|
||||
BoxWithConstraints(Modifier.padding(start = 6.dp, end = 6.dp, bottom = DEFAULT_PADDING).align(Alignment.CenterHorizontally)) {
|
||||
val size = ((maxWidth - DEFAULT_PADDING_HALF * 4) / 5).coerceIn(0.dp, 60.dp)
|
||||
// limiting max width for tablets/wide screens, will be displayed in the center
|
||||
val padding = ((min(420.dp, maxWidth) - size * 5) / 4).coerceAtLeast(0.dp)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(padding), verticalAlignment = Alignment.CenterVertically) {
|
||||
ToggleMicButton(call, enabled, toggleAudio, size = size)
|
||||
SelectSoundDevice(size = size)
|
||||
ControlButton(painterResource(MR.images.ic_call_end_filled), MR.strings.icon_descr_hang_up, enabled = enabled, dismiss, background = Color.Red, size = size, iconPaddingPercent = 0.166f)
|
||||
if (call.localMediaSources.camera) {
|
||||
ControlButton(painterResource(MR.images.ic_flip_camera_android_filled), MR.strings.icon_descr_flip_camera, enabled, flipCamera, size = size)
|
||||
ControlButton(painterResource(MR.images.ic_videocam_filled), MR.strings.icon_descr_video_off, enabled, toggleVideo, size = size)
|
||||
} else {
|
||||
Spacer(Modifier.size(size))
|
||||
ControlButton(painterResource(MR.images.ic_videocam_off), MR.strings.icon_descr_video_on, enabled, toggleVideo, size = size)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -384,34 +396,52 @@ private fun ActiveCallOverlayLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ControlButton(call: Call, icon: Painter, iconText: StringResource, enabled: Boolean = true, action: () -> Unit) {
|
||||
if (call.hasMedia) {
|
||||
IconButton(onClick = action, enabled = enabled) {
|
||||
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.size(40.dp))
|
||||
}
|
||||
} else {
|
||||
Spacer(Modifier.size(40.dp))
|
||||
private fun ControlButton(icon: Painter, iconText: StringResource, enabled: Boolean = true, action: () -> Unit, background: Color = controlButtonsBackground(), size: Dp, iconPaddingPercent: Float = 0.2f) {
|
||||
ControlButtonWrap(enabled, action, background, size) {
|
||||
Icon(icon, stringResource(iconText), tint = if (enabled) Color(0xFFFFFFD8) else MaterialTheme.colors.secondary, modifier = Modifier.padding(size * iconPaddingPercent).fillMaxSize())
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleAudioButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit) {
|
||||
if (call.audioEnabled) {
|
||||
ControlButton(call, painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, enabled, toggleAudio)
|
||||
} else {
|
||||
ControlButton(call, painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, enabled, toggleAudio)
|
||||
private fun ControlButtonWrap(enabled: Boolean = true, action: () -> Unit, background: Color = controlButtonsBackground(), size: Dp, content: @Composable () -> Unit) {
|
||||
Box(
|
||||
Modifier
|
||||
.background(background, CircleShape)
|
||||
.size(size)
|
||||
.clickable(
|
||||
onClick = action,
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false, radius = size / 2, color = background.lighter(0.1f)),
|
||||
enabled = enabled
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleSoundButton(call: Call, enabled: Boolean, speaker: Boolean, toggleSound: () -> Unit) {
|
||||
if (speaker) {
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, enabled, toggleSound)
|
||||
private fun ToggleMicButton(call: Call, enabled: Boolean = true, toggleAudio: () -> Unit, size: Dp) {
|
||||
if (call.localMediaSources.mic) {
|
||||
ControlButton(painterResource(MR.images.ic_mic), MR.strings.icon_descr_audio_off, enabled, toggleAudio, size = size)
|
||||
} else {
|
||||
ControlButton(call, painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, enabled, toggleSound)
|
||||
ControlButton(painterResource(MR.images.ic_mic_off), MR.strings.icon_descr_audio_on, enabled, toggleAudio, size = size)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleSoundButton(enabled: Boolean, speaker: Boolean, muted: Boolean, toggleSound: () -> Unit, size: Dp) {
|
||||
when {
|
||||
muted -> ControlButton(painterResource(MR.images.ic_volume_off), MR.strings.icon_descr_sound_muted, enabled, toggleSound, size = size)
|
||||
speaker -> ControlButton(painterResource(MR.images.ic_volume_up), MR.strings.icon_descr_speaker_off, enabled, toggleSound, size = size)
|
||||
else -> ControlButton(painterResource(MR.images.ic_volume_down), MR.strings.icon_descr_speaker_on, enabled, toggleSound, size = size)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun controlButtonsBackground(): Color = if (chatModel.activeCall.value?.peerMediaSources?.hasVideo == true) Color.Black.copy(0.2f) else Color.White.copy(0.2f)
|
||||
|
||||
@Composable
|
||||
fun AudioCallInfoView(call: Call) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
@@ -553,38 +583,39 @@ fun CallPermissionsView(pipActive: Boolean, hasVideo: Boolean, cancel: () -> Uni
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ColumnWithScrollBar(Modifier.fillMaxSize()) {
|
||||
Spacer(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier))
|
||||
|
||||
AppBarTitle(stringResource(MR.strings.permissions_required))
|
||||
Spacer(Modifier.weight(1f))
|
||||
|
||||
val onClick = {
|
||||
if (permissionsState.shouldShowRationale) {
|
||||
context.showAllowPermissionInSettingsAlert()
|
||||
} else {
|
||||
permissionsState.launchMultiplePermissionRequestWithFallback(buttonEnabled, context::showAllowPermissionInSettingsAlert)
|
||||
ModalView(background = Color.Black, showClose = false, close = {}) {
|
||||
ColumnWithScrollBar(Modifier.fillMaxSize()) {
|
||||
AppBarTitle(stringResource(MR.strings.permissions_required))
|
||||
Spacer(Modifier.weight(1f))
|
||||
val onClick = {
|
||||
if (permissionsState.shouldShowRationale) {
|
||||
context.showAllowPermissionInSettingsAlert()
|
||||
} else {
|
||||
permissionsState.launchMultiplePermissionRequestWithFallback(buttonEnabled, context::showAllowPermissionInSettingsAlert)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(stringResource(MR.strings.permissions_grant), Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), textAlign = TextAlign.Center, color = Color(0xFFFFFFD8))
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
val text = if (hasVideo && audioPermission.status is PermissionStatus.Denied && cameraPermission.status is PermissionStatus.Denied) {
|
||||
stringResource(MR.strings.permissions_camera_and_record_audio)
|
||||
} else if (audioPermission.status is PermissionStatus.Denied) {
|
||||
stringResource(MR.strings.permissions_record_audio)
|
||||
} else if (hasVideo && cameraPermission.status is PermissionStatus.Denied) {
|
||||
stringResource(MR.strings.permissions_camera)
|
||||
} else ""
|
||||
GrantPermissionButton(text, buttonEnabled.value, onClick)
|
||||
Text(stringResource(MR.strings.permissions_grant), Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), textAlign = TextAlign.Center, color = Color(0xFFFFFFD8))
|
||||
SectionSpacer()
|
||||
SectionView {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
val text = if (hasVideo && audioPermission.status is PermissionStatus.Denied && cameraPermission.status is PermissionStatus.Denied) {
|
||||
stringResource(MR.strings.permissions_camera_and_record_audio)
|
||||
} else if (audioPermission.status is PermissionStatus.Denied) {
|
||||
stringResource(MR.strings.permissions_record_audio)
|
||||
} else if (hasVideo && cameraPermission.status is PermissionStatus.Denied) {
|
||||
stringResource(MR.strings.permissions_camera)
|
||||
} else null
|
||||
if (text != null) {
|
||||
GrantPermissionButton(text, buttonEnabled.value, onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = if (hasVideo) 0.dp else DEFAULT_BOTTOM_PADDING), contentAlignment = Alignment.Center) {
|
||||
SimpleButtonFrame(cancel, Modifier.height(64.dp)) {
|
||||
Text(stringResource(MR.strings.call_service_notification_end_call), fontSize = 20.sp, color = Color(0xFFFFFFD8))
|
||||
Spacer(Modifier.weight(1f))
|
||||
Box(Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
|
||||
SimpleButtonFrame(cancel, Modifier.height(60.dp)) {
|
||||
Text(stringResource(MR.strings.call_service_notification_end_call), fontSize = 20.sp, color = Color(0xFFFFFFD8))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -706,7 +737,11 @@ fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIM
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error initializing WebView: ${e.stackTraceToString()}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), generalGetString(MR.strings.error_initializing_web_view).format(e.stackTraceToString()))
|
||||
if (e.stackTraceToString().contains("/lib64")) {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), generalGetString(MR.strings.error_initializing_web_view_wrong_arch).format(e.stackTraceToString()))
|
||||
} else {
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error), generalGetString(MR.strings.error_initializing_web_view).format(e.stackTraceToString()))
|
||||
}
|
||||
return@AndroidView View(androidAppContext)
|
||||
}
|
||||
}
|
||||
@@ -768,8 +803,8 @@ fun PreviewActiveCallOverlayVideo() {
|
||||
userProfile = Profile.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Video,
|
||||
peerMedia = CallMediaType.Video,
|
||||
initialCallType = CallMediaType.Video,
|
||||
peerMediaSources = CallMediaSources(),
|
||||
callUUID = "",
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "tcp"),
|
||||
@@ -798,8 +833,8 @@ fun PreviewActiveCallOverlayAudio() {
|
||||
userProfile = Profile.sampleData,
|
||||
contact = Contact.sampleData,
|
||||
callState = CallState.Negotiated,
|
||||
localMedia = CallMediaType.Audio,
|
||||
peerMedia = CallMediaType.Audio,
|
||||
initialCallType = CallMediaType.Audio,
|
||||
peerMediaSources = CallMediaSources(),
|
||||
callUUID = "",
|
||||
connectionInfo = ConnectionInfo(
|
||||
RTCIceCandidate(RTCIceCandidateType.Host, "udp"),
|
||||
|
||||
@@ -54,8 +54,7 @@ actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
.align(Alignment.BottomCenter),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val media = call.peerMedia ?: call.localMedia
|
||||
if (media == CallMediaType.Video) {
|
||||
if (call.hasVideo) {
|
||||
Icon(painterResource(MR.images.ic_videocam_filled), null, Modifier.size(27.dp).offset(x = 2.5.dp, y = 2.dp), tint = Color.White)
|
||||
} else {
|
||||
Icon(painterResource(MR.images.ic_call_filled), null, Modifier.size(27.dp).offset(x = -0.5.dp, y = 2.dp), tint = Color.White)
|
||||
|
||||
@@ -5,14 +5,19 @@ import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.gestures.Orientation
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.DrawerDefaults.ScrimOpacity
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.graphics.*
|
||||
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
|
||||
@@ -21,95 +26,101 @@ 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 dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
private val USER_PICKER_IMAGE_SIZE = 44.dp
|
||||
private val USER_PICKER_ROW_PADDING = 16.dp
|
||||
|
||||
@Composable
|
||||
actual fun UserPickerInactiveUsersSection(
|
||||
actual fun UserPickerUsersSection(
|
||||
users: List<UserInfo>,
|
||||
stopped: Boolean,
|
||||
onShowAllProfilesClicked: () -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
val screenWidthDp = windowWidth()
|
||||
|
||||
if (users.isNotEmpty()) {
|
||||
SectionItemView(
|
||||
padding = PaddingValues(
|
||||
start = 16.dp,
|
||||
top = if (windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL else DEFAULT_PADDING_HALF,
|
||||
bottom = DEFAULT_PADDING_HALF),
|
||||
padding = PaddingValues(),
|
||||
disabled = stopped
|
||||
) {
|
||||
Box {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = DEFAULT_PADDING + 30.dp).horizontalScroll(scrollState)
|
||||
modifier = Modifier.horizontalScroll(scrollState),
|
||||
) {
|
||||
users.forEach { u ->
|
||||
UserPickerInactiveUserBadge(u, stopped) {
|
||||
onUserClicked(it)
|
||||
withBGApi {
|
||||
delay(500)
|
||||
scrollState.scrollTo(0)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(USER_PICKER_ROW_PADDING)) {
|
||||
users.forEach { u ->
|
||||
UserPickerUserBox(u, stopped, modifier = Modifier.userBoxWidth(u.user, users.size, screenWidthDp)) {
|
||||
onUserClicked(it)
|
||||
withBGApi {
|
||||
delay(500)
|
||||
scrollState.scrollTo(0)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(20.dp))
|
||||
}
|
||||
Spacer(Modifier.width(60.dp))
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(end = DEFAULT_PADDING + 30.dp)
|
||||
.height(60.dp)
|
||||
) {
|
||||
Canvas(modifier = Modifier.size(60.dp)) {
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
CurrentColors.value.colors.surface,
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.height(60.dp)
|
||||
.fillMaxWidth()
|
||||
.padding(end = DEFAULT_PADDING)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onShowAllProfilesClicked,
|
||||
enabled = !stopped
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_chevron_right),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(DEFAULT_PADDING))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
onShowAllProfilesClicked
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun UserPickerUserBox(
|
||||
userInfo: UserInfo,
|
||||
stopped: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: (user: User) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.userPickerBoxModifier()
|
||||
.clickable (
|
||||
onClick = { onClick(userInfo.user) },
|
||||
enabled = !stopped
|
||||
)
|
||||
.background(MaterialTheme.colors.background)
|
||||
.padding(USER_PICKER_ROW_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(USER_PICKER_ROW_PADDING)
|
||||
) {
|
||||
Box {
|
||||
ProfileImageForActiveCall(size = USER_PICKER_IMAGE_SIZE, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||
|
||||
if (userInfo.unreadCount > 0 && !userInfo.user.activeUser) {
|
||||
unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs, false)
|
||||
}
|
||||
}
|
||||
val user = userInfo.user
|
||||
Text(
|
||||
user.displayName,
|
||||
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Modifier.userPickerBoxModifier(): Modifier {
|
||||
val percent = remember { appPreferences.profileImageCornerRadius.state }
|
||||
val r = kotlin.math.max(0f, percent.value)
|
||||
|
||||
val cornerSize = when {
|
||||
r >= 50 -> 50
|
||||
r <= 0 -> 0
|
||||
else -> r.toInt()
|
||||
}
|
||||
|
||||
val shape = RoundedCornerShape(CornerSize(cornerSize))
|
||||
return this.clip(shape).border(1.dp, MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 1 - userPickerAlpha() - 0.02f), shape)
|
||||
}
|
||||
|
||||
|
||||
private fun calculateFraction(pos: Float) =
|
||||
(pos / 1f).coerceIn(0f, 1f)
|
||||
|
||||
@@ -150,7 +161,7 @@ actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<
|
||||
isLight = colors.isLight,
|
||||
drawerShadingColor = shadingColor,
|
||||
toolbarOnTop = !appPrefs.oneHandUI.get(),
|
||||
navBarColor = colors.surface
|
||||
navBarColor = colors.background.mixWith(colors.onBackground, 1 - userPickerAlpha())
|
||||
)
|
||||
} else if (ModalManager.start.modalCount.value == 0) {
|
||||
platform.androidSetDrawerStatusAndNavBarColor(
|
||||
@@ -220,3 +231,13 @@ private fun Modifier.draggableBottomDrawerModifier(
|
||||
orientation = Orientation.Vertical,
|
||||
resistance = null
|
||||
)
|
||||
|
||||
private fun Modifier.userBoxWidth(user: User, totalUsers: Int, windowWidth: Dp): Modifier {
|
||||
return if (totalUsers == 1) {
|
||||
this.width(windowWidth - DEFAULT_PADDING * 2)
|
||||
} else if (user.activeUser) {
|
||||
this.width(windowWidth - DEFAULT_PADDING - (USER_PICKER_ROW_PADDING * 3) - USER_PICKER_IMAGE_SIZE)
|
||||
} else {
|
||||
this.widthIn(max = (windowWidth - (DEFAULT_PADDING * 2)) * 0.618f)
|
||||
}
|
||||
}
|
||||
@@ -806,6 +806,7 @@ data class User(
|
||||
val profile: LocalProfile,
|
||||
val fullPreferences: FullChatPreferences,
|
||||
override val activeUser: Boolean,
|
||||
val activeOrder: Long,
|
||||
override val showNtfs: Boolean,
|
||||
val sendRcptsContacts: Boolean,
|
||||
val sendRcptsSmallGroups: Boolean,
|
||||
@@ -833,6 +834,7 @@ data class User(
|
||||
profile = LocalProfile.sampleData,
|
||||
fullPreferences = FullChatPreferences.sampleData,
|
||||
activeUser = true,
|
||||
activeOrder = 0,
|
||||
showNtfs = true,
|
||||
sendRcptsContacts = true,
|
||||
sendRcptsSmallGroups = false,
|
||||
@@ -2354,7 +2356,8 @@ data class CIMeta (
|
||||
val deletable: Boolean,
|
||||
val editable: Boolean
|
||||
) {
|
||||
val timestampText: String get() = getTimestampText(itemTs)
|
||||
val timestampText: String get() = getTimestampText(itemTs, true)
|
||||
|
||||
val recent: Boolean get() = updatedAt + 10.toDuration(DurationUnit.SECONDS) > Clock.System.now()
|
||||
val isLive: Boolean get() = itemLive == true
|
||||
val disappearing: Boolean get() = !isRcvNew && itemTimed?.deleteAt != null
|
||||
@@ -2418,7 +2421,18 @@ data class CITimed(
|
||||
val deleteAt: Instant?
|
||||
)
|
||||
|
||||
fun getTimestampText(t: Instant): String {
|
||||
fun getTimestampDateText(t: Instant): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val time = t.toLocalDateTime(tz).toJavaLocalDateTime()
|
||||
val weekday = time.format(DateTimeFormatter.ofPattern("EEE"))
|
||||
val dayMonthYear = time.format(DateTimeFormatter.ofPattern(
|
||||
if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM YYYY")
|
||||
)
|
||||
|
||||
return "$weekday, $dayMonthYear"
|
||||
}
|
||||
|
||||
fun getTimestampText(t: Instant, shortFormat: Boolean = false): String {
|
||||
val tz = TimeZone.currentSystemDefault()
|
||||
val now: LocalDateTime = Clock.System.now().toLocalDateTime(tz)
|
||||
val time: LocalDateTime = t.toLocalDateTime(tz)
|
||||
@@ -2426,16 +2440,23 @@ fun getTimestampText(t: Instant): String {
|
||||
val recent = now.date == time.date ||
|
||||
(period.years == 0 && period.months == 0 && period.days == 1 && now.hour < 12 && time.hour >= 18 )
|
||||
val dateFormatter =
|
||||
if (recent) {
|
||||
if (recent || shortFormat) {
|
||||
DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
} else {
|
||||
val dayMonthFormat = when (Locale.getDefault().country) {
|
||||
"US" -> "M/dd"
|
||||
"DE" -> "dd.MM"
|
||||
"RU" -> "dd.MM"
|
||||
else -> "dd/MM"
|
||||
}
|
||||
val dayMonthYearFormat = when (Locale.getDefault().country) {
|
||||
"US" -> "M/dd/yy"
|
||||
"DE" -> "dd.MM.yy"
|
||||
"RU" -> "dd.MM.yy"
|
||||
else -> "dd/MM/yy"
|
||||
}
|
||||
DateTimeFormatter.ofPattern(
|
||||
when (Locale.getDefault().country) {
|
||||
"US" -> "M/dd"
|
||||
"DE" -> "dd.MM"
|
||||
"RU" -> "dd.MM"
|
||||
else -> "dd/MM"
|
||||
}
|
||||
if (now.year == time.year) dayMonthFormat else dayMonthYearFormat
|
||||
)
|
||||
// DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT)
|
||||
}
|
||||
|
||||
@@ -2525,7 +2525,7 @@ object ChatController {
|
||||
// TODO askConfirmation?
|
||||
// TODO check encryption is compatible
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, sharedKey = r.sharedKey)
|
||||
val useRelay = appPrefs.webrtcPolicyRelay.get()
|
||||
val iceServers = getIceServers()
|
||||
Log.d(TAG, ".callOffer iceServers $iceServers")
|
||||
@@ -3653,6 +3653,7 @@ data class NetCfg(
|
||||
val sessionMode: TransportSessionMode = TransportSessionMode.User,
|
||||
val smpProxyMode: SMPProxyMode = SMPProxyMode.Unknown,
|
||||
val smpProxyFallback: SMPProxyFallback = SMPProxyFallback.AllowProtected,
|
||||
val smpWebPort: Boolean = false,
|
||||
val tcpConnectTimeout: Long, // microseconds
|
||||
val tcpTimeout: Long, // microseconds
|
||||
val tcpTimeoutPerKb: Long, // microseconds
|
||||
|
||||
@@ -49,7 +49,7 @@ class CallManager(val chatModel: ChatModel) {
|
||||
contact = invitation.contact,
|
||||
callUUID = invitation.callUUID,
|
||||
callState = CallState.InvitationAccepted,
|
||||
localMedia = invitation.callType.media,
|
||||
initialCallType = invitation.callType.media,
|
||||
sharedKey = invitation.sharedKey,
|
||||
)
|
||||
showCallView.value = true
|
||||
|
||||
@@ -2,6 +2,7 @@ package chat.simplex.common.views.call
|
||||
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.appPlatform
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.datetime.Instant
|
||||
import kotlinx.serialization.SerialName
|
||||
@@ -15,18 +16,21 @@ data class Call(
|
||||
val contact: Contact,
|
||||
val callUUID: String?,
|
||||
val callState: CallState,
|
||||
val localMedia: CallMediaType,
|
||||
val initialCallType: CallMediaType,
|
||||
val localMediaSources: CallMediaSources = CallMediaSources(mic = true, camera = initialCallType == CallMediaType.Video),
|
||||
val localCapabilities: CallCapabilities? = null,
|
||||
val peerMedia: CallMediaType? = null,
|
||||
val peerMediaSources: CallMediaSources = CallMediaSources(),
|
||||
val sharedKey: String? = null,
|
||||
val audioEnabled: Boolean = true,
|
||||
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
||||
var localCamera: VideoCamera = VideoCamera.User,
|
||||
val connectionInfo: ConnectionInfo? = null,
|
||||
var connectedAt: Instant? = null,
|
||||
|
||||
// When a user has audio call, and then he wants to enable camera but didn't grant permissions for using camera yet,
|
||||
// we show permissions view without enabling camera before permissions are granted. After they are granted, enabling camera
|
||||
val wantsToEnableCamera: Boolean = false
|
||||
) {
|
||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||
private val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||
|
||||
val encryptionStatus: String get() = when(callState) {
|
||||
CallState.WaitCapabilities -> ""
|
||||
@@ -35,10 +39,8 @@ data class Call(
|
||||
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
|
||||
}
|
||||
|
||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||
|
||||
fun supportsVideo(): Boolean = peerMedia == CallMediaType.Video || localMedia == CallMediaType.Video
|
||||
|
||||
val hasVideo: Boolean
|
||||
get() = localMediaSources.hasVideo || peerMediaSources.hasVideo
|
||||
}
|
||||
|
||||
enum class CallState {
|
||||
@@ -68,6 +70,16 @@ enum class CallState {
|
||||
@Serializable data class WVAPICall(val corrId: Int? = null, val command: WCallCommand)
|
||||
@Serializable data class WVAPIMessage(val corrId: Int? = null, val resp: WCallResponse, val command: WCallCommand? = null)
|
||||
|
||||
@Serializable data class CallMediaSources(
|
||||
val mic: Boolean = false,
|
||||
val camera: Boolean = false,
|
||||
val screenAudio: Boolean = false,
|
||||
val screenVideo: Boolean = false
|
||||
) {
|
||||
val hasVideo: Boolean
|
||||
get() = camera || screenVideo
|
||||
}
|
||||
|
||||
@Serializable
|
||||
sealed class WCallCommand {
|
||||
@Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
|
||||
@@ -75,7 +87,7 @@ sealed class WCallCommand {
|
||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("media") data class Media(val source: CallMediaSource, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
|
||||
@Serializable @SerialName("layout") data class Layout(val layout: LayoutType): WCallCommand()
|
||||
@@ -90,6 +102,7 @@ sealed class WCallResponse {
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("peerMedia") data class PeerMedia(val source: CallMediaSource, val enabled: Boolean): WCallResponse()
|
||||
@Serializable @SerialName("end") object End: WCallResponse()
|
||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||
@@ -165,6 +178,14 @@ enum class CallMediaType {
|
||||
@SerialName("audio") Audio
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class CallMediaSource {
|
||||
@SerialName("mic") Mic,
|
||||
@SerialName("camera") Camera,
|
||||
@SerialName("screenAudio") ScreenAudio,
|
||||
@SerialName("screenVideo") ScreenVideo
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class VideoCamera {
|
||||
@SerialName("user") User,
|
||||
|
||||
@@ -12,8 +12,10 @@ 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.graphics.*
|
||||
import androidx.compose.ui.layout.layoutId
|
||||
import androidx.compose.ui.platform.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -24,6 +26,7 @@ import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.CIDirection.GroupRcv
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
@@ -40,11 +43,14 @@ import chat.simplex.common.views.newchat.ContactConnectionInfoView
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.*
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.sign
|
||||
|
||||
data class ItemSeparation(val timestamp: Boolean, val largeGap: Boolean, val date: Instant?)
|
||||
|
||||
@Composable
|
||||
// staleChatId means the id that was before chatModel.chatId becomes null. It's needed for Android only to make transition from chat
|
||||
// to chat list smooth. Otherwise, chat view will become blank right before the transition starts
|
||||
@@ -575,7 +581,7 @@ fun startChatCall(remoteHostId: Long?, chatInfo: ChatInfo, media: CallMediaType)
|
||||
if (chatInfo is ChatInfo.Direct) {
|
||||
val contactInfo = chatModel.controller.apiContactInfo(remoteHostId, chatInfo.contact.contactId)
|
||||
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
|
||||
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callUUID = null, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
|
||||
chatModel.activeCall.value = Call(remoteHostId = remoteHostId, contact = chatInfo.contact, callUUID = null, callState = CallState.WaitCapabilities, initialCallType = media, userProfile = profile)
|
||||
chatModel.showCallView.value = true
|
||||
chatModel.callCommand.add(WCallCommand.Capabilities(media))
|
||||
}
|
||||
@@ -774,30 +780,16 @@ fun ChatInfoToolbar(
|
||||
if (chatInfo is ChatInfo.Direct && chatInfo.contact.mergedPreferences.calls.enabled.forUser) {
|
||||
if (activeCall == null) {
|
||||
barButtons.add {
|
||||
if (appPlatform.isAndroid) {
|
||||
IconButton({
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
}, enabled = chatInfo.contact.ready && chatInfo.contact.active
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_call_500),
|
||||
stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current),
|
||||
tint = if (chatInfo.contact.ready && chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton({
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Video)
|
||||
}, enabled = chatInfo.contact.ready && chatInfo.contact.active
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_videocam),
|
||||
stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current),
|
||||
tint = if (chatInfo.contact.ready && chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
IconButton({
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
}, enabled = chatInfo.contact.ready && chatInfo.contact.active
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_call_500),
|
||||
stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current),
|
||||
tint = if (chatInfo.contact.ready && chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (activeCall?.contact?.id == chatInfo.id && appPlatform.isDesktop) {
|
||||
@@ -831,17 +823,10 @@ fun ChatInfoToolbar(
|
||||
}
|
||||
if (chatInfo.contact.ready && chatInfo.contact.active && activeCall == null) {
|
||||
menuItems.add {
|
||||
if (appPlatform.isAndroid) {
|
||||
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Video)
|
||||
})
|
||||
} else {
|
||||
ItemAction(stringResource(MR.strings.icon_descr_audio_call).capitalize(Locale.current), painterResource(MR.images.ic_call_500), onClick = {
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
})
|
||||
}
|
||||
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Video)
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canAddMembers) {
|
||||
@@ -1033,25 +1018,6 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
// With default touchSlop when you scroll LazyColumn, you can unintentionally open reply view
|
||||
LocalViewConfiguration provides LocalViewConfiguration.current.bigTouchSlop()
|
||||
) {
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
scope.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)
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val provider = {
|
||||
providerForGallery(i, chatModel.chatItems.value, cItem.id) { indexInReversed ->
|
||||
scope.launch {
|
||||
@@ -1066,18 +1032,56 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
val revealed = remember { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) {
|
||||
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: IntRange?, fillMaxWidth: Boolean = true) {
|
||||
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, 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)
|
||||
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, showTimestamp = itemSeparation.timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) {
|
||||
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?, itemSeparation: ItemSeparation, previousItemSeparation: ItemSeparation?) {
|
||||
val dismissState = rememberDismissState(initialValue = DismissValue.Default) {
|
||||
if (it == DismissValue.DismissedToStart) {
|
||||
scope.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)
|
||||
} else if (cItem.id != ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
val swipeableModifier = SwipeToDismissModifier(
|
||||
state = dismissState,
|
||||
directions = setOf(DismissDirection.EndToStart),
|
||||
swipeDistance = with(LocalDensity.current) { 30.dp.toPx() },
|
||||
)
|
||||
val sent = cItem.chatDir.sent
|
||||
Box(Modifier.padding(bottom = 4.dp)) {
|
||||
|
||||
@Composable
|
||||
fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) {
|
||||
Box(
|
||||
modifier = modifier.padding(
|
||||
bottom = if (itemSeparation.largeGap) {
|
||||
if (i == 0) {
|
||||
8.dp
|
||||
} else {
|
||||
4.dp
|
||||
}
|
||||
} else 1.dp, top = if (previousItemSeparation?.largeGap == true) 4.dp else 1.dp
|
||||
),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
Box {
|
||||
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null
|
||||
val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf
|
||||
val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp)
|
||||
@@ -1095,46 +1099,66 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
Column(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp),
|
||||
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
|
||||
.fillMaxWidth()
|
||||
.then(swipeableModifier),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalAlignment = Alignment.Start
|
||||
) {
|
||||
if (cItem.content.showMemberName) {
|
||||
val memberNameStyle = SpanStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
|
||||
val memberNameString = if (memCount == 1 && member.memberRole > GroupMemberRole.Member) {
|
||||
buildAnnotatedString {
|
||||
withStyle(memberNameStyle.copy(fontWeight = FontWeight.Medium)) { append(member.memberRole.text) }
|
||||
append(" ")
|
||||
withStyle(memberNameStyle) { append(memberNames(member, prevMember, memCount)) }
|
||||
}
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
withStyle(memberNameStyle) { append(memberNames(member, prevMember, memCount)) }
|
||||
@Composable
|
||||
fun MemberNameAndRole() {
|
||||
Row(Modifier.padding(bottom = 2.dp).graphicsLayer { translationX = selectionOffset.toPx() }, horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
memberNames(member, prevMember, memCount),
|
||||
Modifier
|
||||
.padding(start = MEMBER_IMAGE_SIZE + DEFAULT_PADDING_HALF)
|
||||
.weight(1f, false),
|
||||
fontSize = 13.5.sp,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1
|
||||
)
|
||||
if (memCount == 1 && member.memberRole > GroupMemberRole.Member) {
|
||||
Text(
|
||||
member.memberRole.text,
|
||||
Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF),
|
||||
fontSize = 13.5.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colors.secondary,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
memberNameString,
|
||||
Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp),
|
||||
maxLines = 2
|
||||
)
|
||||
}
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier, cItem.id, selectedChatItems)
|
||||
}
|
||||
Row(
|
||||
swipeableOrSelectionModifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
|
||||
MemberImage(member)
|
||||
|
||||
@Composable
|
||||
fun Item() {
|
||||
ChatItemBox(Modifier.layoutId(CHAT_BUBBLE_LAYOUT_ID)) {
|
||||
androidx.compose.animation.AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier, cItem.id, selectedChatItems)
|
||||
}
|
||||
Row(Modifier.graphicsLayer { translationX = selectionOffset.toPx() },
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) {
|
||||
MemberImage(member)
|
||||
}
|
||||
Box(modifier = Modifier.padding(top = 2.dp)) {
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range, false)
|
||||
}
|
||||
}
|
||||
ChatItemViewShortHand(cItem, range)
|
||||
}
|
||||
}
|
||||
if (cItem.content.showMemberName) {
|
||||
DependentLayout(Modifier, CHAT_BUBBLE_LAYOUT_ID) {
|
||||
MemberNameAndRole()
|
||||
Item()
|
||||
}
|
||||
} else {
|
||||
Item()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
ChatItemBox {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
@@ -1143,12 +1167,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
|
||||
.then(swipeableOrSelectionModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, range)
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
ChatItemBox {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
@@ -1157,12 +1181,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
|
||||
.then(if (selectionVisible) Modifier else swipeableModifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, range)
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
Box(contentAlignment = Alignment.CenterStart) {
|
||||
ChatItemBox {
|
||||
AnimatedVisibility (selectionVisible, enter = fadeIn(), exit = fadeOut()) {
|
||||
SelectedChatItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems)
|
||||
}
|
||||
@@ -1172,7 +1196,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
|
||||
).then(if (!selectionVisible || !sent) swipeableOrSelectionModifier else Modifier)
|
||||
) {
|
||||
ChatItemViewShortHand(cItem, range)
|
||||
ChatItemViewShortHand(cItem, itemSeparation, range)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1191,17 +1215,30 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
|
||||
} else {
|
||||
val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
|
||||
|
||||
val itemSeparation = getItemSeparation(cItem, nextItem)
|
||||
val previousItemSeparation = if (prevItem != null) getItemSeparation(prevItem, cItem) else null
|
||||
|
||||
if (itemSeparation.date != null) {
|
||||
DateSeparator(itemSeparation.date)
|
||||
}
|
||||
|
||||
val range = chatViewItemsRange(currIndex, prevHidden)
|
||||
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]
|
||||
ChatItemView(ci, null, prev)
|
||||
ChatItemView(ci, null, prev, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
} else {
|
||||
ChatItemView(cItem, range, prevItem)
|
||||
ChatItemView(cItem, range, prevItem, itemSeparation, previousItemSeparation)
|
||||
}
|
||||
|
||||
if (i == reversedChatItems.lastIndex) {
|
||||
DateSeparator(cItem.meta.itemTs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (cItem.isRcvNew && chatInfo.id == ChatModel.chatId.value) {
|
||||
LaunchedEffect(cItem.id) {
|
||||
scope.launch {
|
||||
@@ -1214,6 +1251,12 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
}
|
||||
}
|
||||
FloatingButtons(chatModel.chatItems, unreadCount, remoteHostId, chatInfo, searchValue, markRead, setFloatingButton, listState)
|
||||
|
||||
FloatingDate(
|
||||
Modifier.padding(top = 10.dp).align(Alignment.TopCenter),
|
||||
listState,
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.isScrollInProgress }
|
||||
.collect {
|
||||
@@ -1405,7 +1448,7 @@ private fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean =
|
||||
else -> false
|
||||
}
|
||||
|
||||
val MEMBER_IMAGE_SIZE: Dp = 38.dp
|
||||
val MEMBER_IMAGE_SIZE: Dp = 37.dp
|
||||
|
||||
@Composable
|
||||
fun MemberImage(member: GroupMember) {
|
||||
@@ -1440,6 +1483,108 @@ private fun TopEndFloatingButton(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FloatingDate(
|
||||
modifier: Modifier,
|
||||
listState: LazyListState,
|
||||
) {
|
||||
var nearBottomIndex by remember { mutableStateOf(-1) }
|
||||
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
|
||||
val item = chatModel.chatItems.value.getOrNull(lastVisibleChatItemIndex)
|
||||
val timeZone = TimeZone.currentSystemDefault()
|
||||
item?.meta?.itemTs?.toLocalDateTime(timeZone)?.date?.atStartOfDayIn(timeZone)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
val showDate = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
launch {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
showDate.value = false
|
||||
isNearBottom = true
|
||||
nearBottomIndex = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo }
|
||||
.collect { visibleItemsInfo ->
|
||||
if (visibleItemsInfo.find { it.index == 0 } != null) {
|
||||
var elapsedOffset = 0
|
||||
|
||||
for (it in visibleItemsInfo) {
|
||||
if (elapsedOffset >= listState.layoutInfo.viewportSize.height / 2.5) {
|
||||
nearBottomIndex = it.index
|
||||
break;
|
||||
}
|
||||
elapsedOffset += it.size
|
||||
}
|
||||
}
|
||||
|
||||
isNearBottom = if (nearBottomIndex == -1) true else (visibleItemsInfo.firstOrNull()?.index ?: 0) <= nearBottomIndex
|
||||
}
|
||||
}
|
||||
|
||||
fun setDateVisibility(isVisible: Boolean) {
|
||||
if (isVisible) {
|
||||
val now = Clock.System.now()
|
||||
val date = lastVisibleItemDate.value
|
||||
if (!isNearBottom && !showDate.value && date != null && getTimestampDateText(date) != getTimestampDateText(now)) {
|
||||
showDate.value = true
|
||||
}
|
||||
} else if (showDate.value) {
|
||||
showDate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
var hideDateWhenNotScrolling: Job = Job()
|
||||
snapshotFlow { listState.firstVisibleItemScrollOffset }
|
||||
.collect {
|
||||
setDateVisibility(true)
|
||||
hideDateWhenNotScrolling.cancel()
|
||||
hideDateWhenNotScrolling = launch {
|
||||
delay(1000)
|
||||
setDateVisibility(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
modifier = modifier,
|
||||
visible = showDate.value,
|
||||
enter = fadeIn(tween(durationMillis = 350)),
|
||||
exit = fadeOut(tween(durationMillis = 350))
|
||||
) {
|
||||
val date = lastVisibleItemDate.value
|
||||
Column {
|
||||
Text(
|
||||
text = if (date != null) getTimestampDateText(date) else "",
|
||||
Modifier
|
||||
.background(
|
||||
color = MaterialTheme.colors.secondaryVariant,
|
||||
RoundedCornerShape(25.dp)
|
||||
)
|
||||
.padding(vertical = 4.dp, horizontal = 8.dp)
|
||||
.clip(RoundedCornerShape(25.dp)),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DownloadFilesButton(
|
||||
forwardConfirmation: ForwardConfirmation.FilesNotAccepted,
|
||||
@@ -1499,6 +1644,18 @@ private fun ButtonRow(horizontalArrangement: Arrangement.Horizontal, content: @C
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateSeparator(date: Instant) {
|
||||
Text(
|
||||
text = getTimestampDateText(date),
|
||||
Modifier.padding(vertical = DEFAULT_PADDING_HALF + 4.dp, horizontal = DEFAULT_PADDING_HALF).fillMaxWidth(),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
|
||||
val chatViewScrollState = MutableStateFlow(false)
|
||||
|
||||
fun addGroupMembers(groupInfo: GroupInfo, rhId: Long?, view: Any? = null, close: (() -> Unit)? = null) {
|
||||
@@ -1872,6 +2029,23 @@ private fun handleForwardConfirmation(
|
||||
)
|
||||
}
|
||||
|
||||
private fun getItemSeparation(chatItem: ChatItem, nextItem: ChatItem?): ItemSeparation {
|
||||
if (nextItem == null) {
|
||||
return ItemSeparation(timestamp = true, largeGap = true, date = null)
|
||||
}
|
||||
|
||||
val sameMemberAndDirection = if (nextItem.chatDir is GroupRcv && chatItem.chatDir is GroupRcv) {
|
||||
chatItem.chatDir.groupMember.groupMemberId == nextItem.chatDir.groupMember.groupMemberId
|
||||
} else chatItem.chatDir.sent == nextItem.chatDir.sent
|
||||
val largeGap = !sameMemberAndDirection || (abs(nextItem.meta.createdAt.epochSeconds - chatItem.meta.createdAt.epochSeconds) >= 60)
|
||||
|
||||
return ItemSeparation(
|
||||
timestamp = largeGap || nextItem.meta.timestampText != chatItem.meta.timestampText,
|
||||
largeGap = largeGap,
|
||||
date = if (getTimestampDateText(chatItem.meta.itemTs) == getTimestampDateText(nextItem.meta.itemTs)) null else nextItem.meta.itemTs
|
||||
)
|
||||
}
|
||||
|
||||
@Preview/*(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
showBackground = true,
|
||||
|
||||
@@ -20,6 +20,7 @@ fun CICallItemView(
|
||||
cItem: ChatItem,
|
||||
status: CICallStatus,
|
||||
duration: Int,
|
||||
showTimestamp: Boolean,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
timedMessagesTTL: Int?
|
||||
) {
|
||||
@@ -47,7 +48,7 @@ fun CICallItemView(
|
||||
CICallStatus.Error -> {}
|
||||
}
|
||||
|
||||
CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false)
|
||||
CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ fun CIGroupInvitationView(
|
||||
ci: ChatItem,
|
||||
groupInvitation: CIGroupInvitation,
|
||||
memberRole: GroupMemberRole,
|
||||
showTimestamp: Boolean,
|
||||
chatIncognito: Boolean = false,
|
||||
joinGroup: (Long, () -> Unit) -> Unit,
|
||||
timedMessagesTTL: Int?
|
||||
@@ -118,7 +119,7 @@ fun CIGroupInvitationView(
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join))
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor)) }
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) }
|
||||
},
|
||||
color = if (inProgress.value)
|
||||
MaterialTheme.colors.secondary
|
||||
@@ -129,7 +130,7 @@ fun CIGroupInvitationView(
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append(groupInvitationStr())
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor)) }
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) }
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -145,7 +146,7 @@ fun CIGroupInvitationView(
|
||||
}
|
||||
}
|
||||
|
||||
CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false)
|
||||
CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,7 +163,8 @@ fun PendingCIGroupInvitationViewPreview() {
|
||||
groupInvitation = CIGroupInvitation.getSample(),
|
||||
memberRole = GroupMemberRole.Admin,
|
||||
joinGroup = { _, _ -> },
|
||||
timedMessagesTTL = null
|
||||
timedMessagesTTL = null,
|
||||
showTimestamp = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -179,8 +181,9 @@ fun CIGroupInvitationViewAcceptedPreview() {
|
||||
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
|
||||
memberRole = GroupMemberRole.Admin,
|
||||
joinGroup = { _, _ -> },
|
||||
timedMessagesTTL = null
|
||||
)
|
||||
timedMessagesTTL = null,
|
||||
showTimestamp = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,7 +199,8 @@ fun CIGroupInvitationViewLongNamePreview() {
|
||||
),
|
||||
memberRole = GroupMemberRole.Admin,
|
||||
joinGroup = { _, _ -> },
|
||||
timedMessagesTTL = null
|
||||
)
|
||||
timedMessagesTTL = null,
|
||||
showTimestamp = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,8 @@ fun CIMetaView(
|
||||
},
|
||||
showStatus: Boolean = true,
|
||||
showEdited: Boolean = true,
|
||||
showViaProxy: Boolean
|
||||
showTimestamp: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
) {
|
||||
Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
if (chatItem.isDeletedContent) {
|
||||
@@ -54,7 +55,8 @@ fun CIMetaView(
|
||||
paleMetaColor,
|
||||
showStatus = showStatus,
|
||||
showEdited = showEdited,
|
||||
showViaProxy = showViaProxy
|
||||
showViaProxy = showViaProxy,
|
||||
showTimestamp = showTimestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -70,11 +72,11 @@ private fun CIMetaText(
|
||||
paleColor: Color,
|
||||
showStatus: Boolean = true,
|
||||
showEdited: Boolean = true,
|
||||
showViaProxy: Boolean
|
||||
showTimestamp: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
) {
|
||||
if (showEdited && meta.itemEdited) {
|
||||
StatusIconText(painterResource(MR.images.ic_edit), color)
|
||||
Spacer(Modifier.width(3.dp))
|
||||
}
|
||||
if (meta.disappearing) {
|
||||
StatusIconText(painterResource(MR.images.ic_timer), color)
|
||||
@@ -82,12 +84,13 @@ private fun CIMetaText(
|
||||
if (ttl != chatTTL) {
|
||||
Text(shortTimeText(ttl), color = color, fontSize = 12.sp)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
if (showViaProxy && meta.sentViaProxy == true) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Icon(painterResource(MR.images.ic_arrow_forward), null, Modifier.height(17.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
if (showStatus) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor)
|
||||
if (statusIcon != null) {
|
||||
val (icon, statusColor) = statusIcon
|
||||
@@ -96,17 +99,19 @@ private fun CIMetaText(
|
||||
} else {
|
||||
StatusIconText(painterResource(icon), statusColor)
|
||||
}
|
||||
Spacer(Modifier.width(4.dp))
|
||||
} else if (!meta.disappearing) {
|
||||
StatusIconText(painterResource(MR.images.ic_circle_filled), Color.Transparent)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
}
|
||||
}
|
||||
if (encrypted != null) {
|
||||
StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
StatusIconText(painterResource(if (encrypted) MR.images.ic_lock else MR.images.ic_lock_open_right), color)
|
||||
}
|
||||
|
||||
if (showTimestamp) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
// the conditions in this function should match CIMetaText
|
||||
@@ -117,28 +122,56 @@ fun reserveSpaceForMeta(
|
||||
secondaryColor: Color,
|
||||
showStatus: Boolean = true,
|
||||
showEdited: Boolean = true,
|
||||
showViaProxy: Boolean = false
|
||||
showViaProxy: Boolean = false,
|
||||
showTimestamp: Boolean
|
||||
): String {
|
||||
val iconSpace = " "
|
||||
var res = ""
|
||||
if (showEdited && meta.itemEdited) res += iconSpace
|
||||
val iconSpace = " \u00A0\u00A0\u00A0"
|
||||
val whiteSpace = "\u00A0"
|
||||
var res = if (showTimestamp) "" else iconSpace
|
||||
var space: String? = null
|
||||
|
||||
fun appendSpace() {
|
||||
if (space != null) {
|
||||
res += space
|
||||
space = null
|
||||
}
|
||||
}
|
||||
|
||||
if (showEdited && meta.itemEdited) {
|
||||
res += iconSpace
|
||||
}
|
||||
if (meta.itemTimed != null) {
|
||||
res += iconSpace
|
||||
val ttl = meta.itemTimed.ttl
|
||||
if (ttl != chatTTL) {
|
||||
res += shortTimeText(ttl)
|
||||
}
|
||||
space = whiteSpace
|
||||
}
|
||||
if (showViaProxy && meta.sentViaProxy == true) {
|
||||
appendSpace()
|
||||
res += iconSpace
|
||||
}
|
||||
if (showStatus && (meta.statusIcon(secondaryColor) != null || !meta.disappearing)) {
|
||||
res += iconSpace
|
||||
if (showStatus) {
|
||||
appendSpace()
|
||||
if (meta.statusIcon(secondaryColor) != null) {
|
||||
res += iconSpace
|
||||
} else if (!meta.disappearing) {
|
||||
res += iconSpace
|
||||
}
|
||||
space = whiteSpace
|
||||
}
|
||||
|
||||
if (encrypted != null) {
|
||||
appendSpace()
|
||||
res += iconSpace
|
||||
space = whiteSpace
|
||||
}
|
||||
return res + meta.timestampText
|
||||
if (showTimestamp) {
|
||||
appendSpace()
|
||||
res += meta.timestampText
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -154,7 +187,8 @@ fun PreviewCIMetaView() {
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -167,7 +201,8 @@ fun PreviewCIMetaViewUnread() {
|
||||
status = CIStatus.RcvNew()
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -180,7 +215,8 @@ fun PreviewCIMetaViewSendFailed() {
|
||||
status = CIStatus.CISSndError(SndError.Other("CMD SYNTAX"))
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,7 +228,8 @@ fun PreviewCIMetaViewSendNoAuth() {
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth()
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -204,7 +241,8 @@ fun PreviewCIMetaViewSendSent() {
|
||||
1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent(SndCIStatusProgress.Complete)
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,7 +255,8 @@ fun PreviewCIMetaViewEdited() {
|
||||
itemEdited = true
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -231,7 +270,8 @@ fun PreviewCIMetaViewEditedUnread() {
|
||||
status= CIStatus.RcvNew()
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -245,7 +285,8 @@ fun PreviewCIMetaViewEditedSent() {
|
||||
status= CIStatus.SndSent(SndCIStatusProgress.Complete)
|
||||
),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -255,6 +296,7 @@ fun PreviewCIMetaViewDeletedContent() {
|
||||
CIMetaView(
|
||||
chatItem = ChatItem.getDeletedContentSampleData(),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
|
||||
@@ -169,14 +169,14 @@ fun DecryptionErrorItemFixButton(
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
append(generalGetString(MR.strings.fix_connection))
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor)) }
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) }
|
||||
withStyle(reserveTimestampStyle) { append(" ") } // for icon
|
||||
},
|
||||
color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false)
|
||||
CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,11 +201,11 @@ fun DecryptionErrorItem(
|
||||
Text(
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) }
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor)) }
|
||||
withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) }
|
||||
},
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp)
|
||||
)
|
||||
CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false)
|
||||
CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ fun CIVoiceView(
|
||||
ci: ChatItem,
|
||||
timedMessagesTTL: Int?,
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
smallView: Boolean = false,
|
||||
longClick: () -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
@@ -86,7 +87,7 @@ fun CIVoiceView(
|
||||
durationText(time / 1000)
|
||||
}
|
||||
}
|
||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, sizeMultiplier, play, pause, longClick, receiveFile) {
|
||||
VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, receiveFile) {
|
||||
AudioPlayer.seekTo(it, progress, fileSource.value?.filePath)
|
||||
}
|
||||
if (smallView) {
|
||||
@@ -120,6 +121,7 @@ private fun VoiceLayout(
|
||||
hasText: Boolean,
|
||||
timedMessagesTTL: Int?,
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
sizeMultiplier: Float,
|
||||
play: () -> Unit,
|
||||
pause: () -> Unit,
|
||||
@@ -200,7 +202,7 @@ private fun VoiceLayout(
|
||||
VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile)
|
||||
}
|
||||
Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier, end = 6.sp.toDp() * sizeMultiplier)) {
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,7 +217,7 @@ private fun VoiceLayout(
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier)) {
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fun ChatItemView(
|
||||
revealed: MutableState<Boolean>,
|
||||
range: IntRange?,
|
||||
selectedChatItems: MutableState<Set<Long>?>,
|
||||
fillMaxWidth: Boolean = true,
|
||||
selectChatItem: () -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
@@ -72,6 +73,7 @@ fun ChatItemView(
|
||||
showItemDetails: (ChatInfo, ChatItem) -> Unit,
|
||||
developerTools: Boolean,
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
preview: Boolean = false,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
@@ -83,7 +85,7 @@ fun ChatItemView(
|
||||
val live = composeState.value.liveMessage != null
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier,
|
||||
contentAlignment = alignment,
|
||||
) {
|
||||
val info = cItem.meta.itemStatus.statusInto
|
||||
@@ -131,7 +133,7 @@ fun ChatItemView(
|
||||
) {
|
||||
@Composable
|
||||
fun framedItemView() {
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, receiveFile, onLinkLongClick, scrollToItem)
|
||||
FramedItemView(cInfo, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, receiveFile, onLinkLongClick, scrollToItem)
|
||||
}
|
||||
|
||||
fun deleteMessageQuestionText(): String {
|
||||
@@ -354,14 +356,14 @@ fun ChatItemView(
|
||||
fun ContentItem() {
|
||||
val mc = cItem.content.msgContent
|
||||
if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) {
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy)
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
MarkedDeletedItemDropdownMenu()
|
||||
} else {
|
||||
if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
|
||||
if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) {
|
||||
EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy)
|
||||
EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
} else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) {
|
||||
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile = receiveFile)
|
||||
CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile)
|
||||
} else {
|
||||
framedItemView()
|
||||
}
|
||||
@@ -373,7 +375,7 @@ fun ChatItemView(
|
||||
}
|
||||
|
||||
@Composable fun LegacyDeletedItem() {
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy)
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
@@ -385,7 +387,7 @@ fun ChatItemView(
|
||||
}
|
||||
|
||||
@Composable fun CallItem(status: CICallStatus, duration: Int) {
|
||||
CICallItemView(cInfo, cItem, status, duration, acceptCall, cInfo.timedMessagesTTL)
|
||||
CICallItemView(cInfo, cItem, status, duration, showTimestamp = showTimestamp, acceptCall, cInfo.timedMessagesTTL)
|
||||
DeleteItemMenu()
|
||||
}
|
||||
|
||||
@@ -430,7 +432,7 @@ fun ChatItemView(
|
||||
|
||||
@Composable
|
||||
fun DeletedItem() {
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy)
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
|
||||
@@ -473,7 +475,7 @@ fun ChatItemView(
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> if (developerTools) {
|
||||
IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL)
|
||||
IntegrityErrorItemView(c.msgError, cItem, showTimestamp, cInfo.timedMessagesTTL)
|
||||
DeleteItemMenu()
|
||||
} else {
|
||||
Box(Modifier.size(0.dp)) {}
|
||||
@@ -483,11 +485,11 @@ fun ChatItemView(
|
||||
DeleteItemMenu()
|
||||
}
|
||||
is CIContent.RcvGroupInvitation -> {
|
||||
CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, timedMessagesTTL = cInfo.timedMessagesTTL)
|
||||
CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL)
|
||||
DeleteItemMenu()
|
||||
}
|
||||
is CIContent.SndGroupInvitation -> {
|
||||
CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, timedMessagesTTL = cInfo.timedMessagesTTL)
|
||||
CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito, showTimestamp = showTimestamp, timedMessagesTTL = cInfo.timedMessagesTTL)
|
||||
DeleteItemMenu()
|
||||
}
|
||||
is CIContent.RcvDirectEventContent -> {
|
||||
@@ -927,6 +929,7 @@ fun PreviewChatItemView(
|
||||
showItemDetails = { _, _ -> },
|
||||
developerTools = false,
|
||||
showViaProxy = false,
|
||||
showTimestamp = true,
|
||||
preview = true,
|
||||
)
|
||||
}
|
||||
@@ -967,6 +970,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
developerTools = false,
|
||||
showViaProxy = false,
|
||||
preview = true,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.ui.theme.*
|
||||
|
||||
@Composable
|
||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean) {
|
||||
fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
val sent = ci.chatDir.sent
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
val receivedColor = MaterialTheme.appColors.receivedMessage
|
||||
@@ -36,7 +36,7 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean)
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,7 +51,8 @@ fun PreviewDeletedItemView() {
|
||||
DeletedItemView(
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,18 +12,19 @@ import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatItem
|
||||
import chat.simplex.common.model.MREmojiChar
|
||||
import chat.simplex.common.ui.theme.EmojiFont
|
||||
import java.sql.Timestamp
|
||||
|
||||
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont)
|
||||
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp, fontFamily = EmojiFont)
|
||||
|
||||
@Composable
|
||||
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean) {
|
||||
fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
Column(
|
||||
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
EmojiText(chatItem.content.text)
|
||||
CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy)
|
||||
CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ fun FramedItemView(
|
||||
linkMode: SimplexLinkMode,
|
||||
showViaProxy: Boolean,
|
||||
showMenu: MutableState<Boolean>,
|
||||
showTimestamp: Boolean,
|
||||
receiveFile: (Long) -> Unit,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
scrollToItem: (Long) -> Unit = {},
|
||||
@@ -47,7 +48,7 @@ fun FramedItemView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ciQuotedMsgTextView(qi: CIQuote, lines: Int) {
|
||||
fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean) {
|
||||
MarkdownText(
|
||||
qi.text,
|
||||
qi.formattedText,
|
||||
@@ -56,7 +57,8 @@ fun FramedItemView(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface),
|
||||
linkMode = linkMode,
|
||||
uriHandler = if (appPlatform.isDesktop) uriHandler else null
|
||||
uriHandler = if (appPlatform.isDesktop) uriHandler else null,
|
||||
showTimestamp = showTimestamp
|
||||
)
|
||||
}
|
||||
|
||||
@@ -76,10 +78,10 @@ fun FramedItemView(
|
||||
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary),
|
||||
maxLines = 1
|
||||
)
|
||||
ciQuotedMsgTextView(qi, lines = 2)
|
||||
ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp)
|
||||
}
|
||||
} else {
|
||||
ciQuotedMsgTextView(qi, lines = 3)
|
||||
ciQuotedMsgTextView(qi, lines = 3, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,7 +180,7 @@ fun FramedItemView(
|
||||
fun ciFileView(ci: ChatItem, text: String) {
|
||||
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile)
|
||||
if (text != "" || ci.meta.isLive) {
|
||||
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy)
|
||||
CIMarkdownText(ci, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +244,7 @@ fun FramedItemView(
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy)
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVideo -> {
|
||||
@@ -250,35 +252,35 @@ fun FramedItemView(
|
||||
if (mc.text == "" && !ci.meta.isLive) {
|
||||
metaColor = Color.White
|
||||
} else {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy)
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVoice -> {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, longClick = { onLinkLongClick("") }, receiveFile = receiveFile)
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile)
|
||||
if (mc.text != "") {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy)
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCFile -> ciFileView(ci, mc.text)
|
||||
is MsgContent.MCUnknown ->
|
||||
if (ci.file == null) {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy)
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
} else {
|
||||
ciFileView(ci, mc.text)
|
||||
}
|
||||
is MsgContent.MCLink -> {
|
||||
ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true })
|
||||
Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) {
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy)
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy)
|
||||
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
|
||||
CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy)
|
||||
CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,19 +293,21 @@ fun CIMarkdownText(
|
||||
linkMode: SimplexLinkMode,
|
||||
uriHandler: UriHandler?,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
MarkdownText(
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
|
||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
|
||||
const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble"
|
||||
/**
|
||||
* 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`
|
||||
@@ -311,23 +315,23 @@ const val CHAT_IMAGE_LAYOUT_ID = "chatImage"
|
||||
* */
|
||||
const val MAX_SAFE_WIDTH = 0x3FFFF - 1
|
||||
|
||||
/**
|
||||
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
private fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
|
||||
width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
|
||||
width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
|
||||
width < 0xFFFF /*MinFocusMask*/ -> 0x7FFF - 1 /* MinFocusMask */ // 16 bits width + 15 bits height
|
||||
width < 0x3FFFF /*MaxFocusMask*/ -> 0x1FFF - 1 /* MaxNonFocusMask */ // 18 bits width + 13 bits height
|
||||
else -> 0x1FFF // shouldn't happen since width is limited already
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PriorityLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
priorityLayoutId: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
/**
|
||||
* Limiting max value for height + width in order to not crash the app, see [androidx.compose.ui.unit.Constraints.createConstraints]
|
||||
* */
|
||||
fun maxSafeHeight(width: Int) = when { // width bits + height bits should be <= 31
|
||||
width < 0x1FFF /*MaxNonFocusMask*/ -> 0x3FFFF - 1 /* MaxFocusMask */ // 13 bits width + 18 bits height
|
||||
width < 0x7FFF /*MinNonFocusMask*/ -> 0xFFFF - 1 /* MinFocusMask */ // 15 bits width + 16 bits height
|
||||
width < 0xFFFF /*MinFocusMask*/ -> 0x7FFF - 1 /* MinFocusMask */ // 16 bits width + 15 bits height
|
||||
width < 0x3FFFF /*MaxFocusMask*/ -> 0x1FFF - 1 /* MaxNonFocusMask */ // 18 bits width + 13 bits height
|
||||
else -> 0x1FFF // shouldn't happen since width is limited already
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
@@ -352,6 +356,36 @@ fun PriorityLayout(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DependentLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
mainLayoutId: String,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
Layout(
|
||||
content = content,
|
||||
modifier = modifier
|
||||
) { measureable, constraints ->
|
||||
// Find important element which should tell what min width it needs to draw itself.
|
||||
// Expecting only one such element. Can be less than one but not more
|
||||
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(minWidth = mainPlaceable?.width ?: 0, maxWidth = min(MAX_SAFE_WIDTH, constraints.maxWidth))) }
|
||||
val width = mainPlaceable?.measuredWidth ?: min(MAX_SAFE_WIDTH, placeables.maxOf { it.width })
|
||||
val height = minOf(maxSafeHeight(width), placeables.sumOf { it.height })
|
||||
layout(width, height) {
|
||||
var y = 0
|
||||
placeables.forEach {
|
||||
it.place(0, y)
|
||||
y += it.measuredHeight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/*
|
||||
|
||||
class EditedProvider: PreviewParameterProvider<Boolean> {
|
||||
|
||||
@@ -23,8 +23,8 @@ import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
|
||||
@Composable
|
||||
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTTL: Int?) {
|
||||
CIMsgError(ci, timedMessagesTTL) {
|
||||
fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?) {
|
||||
CIMsgError(ci, showTimestamp, timedMessagesTTL) {
|
||||
when (msgError) {
|
||||
is MsgErrorType.MsgSkipped ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -49,7 +49,7 @@ fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, timedMessagesTT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
|
||||
fun CIMsgError(ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?, onClick: () -> Unit) {
|
||||
val receivedColor = MaterialTheme.appColors.receivedMessage
|
||||
Surface(
|
||||
Modifier.clickable(onClick = onClick),
|
||||
@@ -68,7 +68,7 @@ fun CIMsgError(ci: ChatItem, timedMessagesTTL: Int?, onClick: () -> Unit) {
|
||||
style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp),
|
||||
modifier = Modifier.padding(end = 8.dp)
|
||||
)
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = false)
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = false, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,8 @@ fun IntegrityErrorItemViewPreview() {
|
||||
IntegrityErrorItemView(
|
||||
MsgErrorType.MsgBadHash(),
|
||||
ChatItem.getDeletedContentSampleData(),
|
||||
null
|
||||
showTimestamp = true,
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>, showViaProxy: Boolean) {
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
val receivedColor = MaterialTheme.appColors.receivedMessage
|
||||
Surface(
|
||||
@@ -35,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: Mutabl
|
||||
Box(Modifier.weight(1f, false)) {
|
||||
MergedMarkedDeletedText(ci, revealed)
|
||||
}
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy)
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,8 @@ fun PreviewMarkedDeletedItemView() {
|
||||
DeletedItemView(
|
||||
ChatItem.getSampleData(itemDeleted = CIDeleted.Deleted(Clock.System.now())),
|
||||
null,
|
||||
showViaProxy = false
|
||||
showViaProxy = false,
|
||||
showTimestamp = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ fun MarkdownText (
|
||||
linkMode: SimplexLinkMode,
|
||||
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean = false
|
||||
showViaProxy: Boolean = false,
|
||||
showTimestamp: Boolean = true
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
@@ -78,7 +79,7 @@ fun MarkdownText (
|
||||
val reserve = if (textLayoutDirection != LocalLayoutDirection.current && meta != null) {
|
||||
"\n"
|
||||
} else if (meta != null) {
|
||||
reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy)
|
||||
reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
} else {
|
||||
" "
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ fun ChatPreviewView(
|
||||
}
|
||||
}
|
||||
is MsgContent.MCVoice -> SmallContentPreviewVoice() {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = false, ci, cInfo.timedMessagesTTL, showViaProxy = false, smallView = true, longClick = {}) {
|
||||
CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = false, ci, cInfo.timedMessagesTTL, showViaProxy = false, showTimestamp = true, smallView = true, longClick = {}) {
|
||||
val user = chatModel.currentUser.value ?: return@CIVoiceView
|
||||
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
|
||||
}
|
||||
@@ -332,7 +332,7 @@ fun ChatPreviewView(
|
||||
chatPreviewTitle()
|
||||
}
|
||||
Spacer(Modifier.width(8.sp.toDp()))
|
||||
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.chatTs)
|
||||
val ts = getTimestampText(chat.chatItems.lastOrNull()?.meta?.itemTs ?: chat.chatInfo.chatTs)
|
||||
ChatListTimestampView(ts)
|
||||
}
|
||||
Row(Modifier.heightIn(min = 46.sp.toDp()).fillMaxWidth()) {
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import SectionView
|
||||
import TextIconSpaced
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.shape.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -22,6 +20,7 @@ import androidx.compose.ui.text.capitalize
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
|
||||
@@ -31,7 +30,6 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.CreateProfile
|
||||
import chat.simplex.common.views.localauth.VerticalDivider
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.common.views.remote.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher
|
||||
@@ -40,6 +38,8 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
private val USER_PICKER_SECTION_SPACING = 32.dp
|
||||
|
||||
@Composable
|
||||
fun UserPicker(
|
||||
chatModel: ChatModel,
|
||||
@@ -56,7 +56,7 @@ fun UserPicker(
|
||||
derivedStateOf {
|
||||
chatModel.users
|
||||
.filter { u -> u.user.activeUser || !u.user.hidden }
|
||||
.sortedByDescending { it.user.activeUser }
|
||||
.sortedByDescending { it.user.activeOrder }
|
||||
}
|
||||
}
|
||||
val remoteHosts by remember {
|
||||
@@ -142,10 +142,33 @@ fun UserPicker(
|
||||
.height(IntrinsicSize.Min)
|
||||
.fillMaxWidth()
|
||||
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
|
||||
.background(MaterialTheme.colors.surface)
|
||||
.padding(vertical = DEFAULT_PADDING),
|
||||
.background(if (appPlatform.isAndroid) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, alpha = 1 - userPickerAlpha()) else MaterialTheme.colors.surface)
|
||||
.padding(bottom = USER_PICKER_SECTION_SPACING - DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
|
||||
pickerState = userPickerState
|
||||
) {
|
||||
val showCustomModal: (@Composable() (ModalData.(ChatModel, () -> Unit) -> Unit)) -> () -> Unit = { modalView ->
|
||||
{
|
||||
ModalManager.start.showCustomModal { close -> modalView(chatModel, close) }
|
||||
}
|
||||
}
|
||||
val stopped = remember { chatModel.chatRunning }.value == false
|
||||
val onUserClicked: (user: User) -> Unit = { user ->
|
||||
if (!user.activeUser) {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
withBGApi {
|
||||
controller.showProgressIfNeeded {
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
chatModel.controller.changeActiveUser(user.remoteHostId, user.userId, null)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }()
|
||||
withBGApi {
|
||||
closePicker(userPickerState)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FirstSection() {
|
||||
if (remoteHosts.isNotEmpty()) {
|
||||
@@ -169,87 +192,24 @@ fun UserPicker(
|
||||
}
|
||||
)
|
||||
}
|
||||
ActiveUserSection(
|
||||
chatModel = chatModel,
|
||||
userPickerState = userPickerState,
|
||||
)
|
||||
val currentUser = remember { chatModel.currentUser }.value
|
||||
if (appPlatform.isAndroid) {
|
||||
Column(modifier = Modifier.padding(top = USER_PICKER_SECTION_SPACING, bottom = USER_PICKER_SECTION_SPACING - DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL - 3.dp)) {
|
||||
UserPickerUsersSection(
|
||||
users = users,
|
||||
onUserClicked = onUserClicked,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SecondSection() {
|
||||
GlobalSettingsSection(
|
||||
chatModel = chatModel,
|
||||
userPickerState = userPickerState,
|
||||
setPerformLA = setPerformLA,
|
||||
onUserClicked = { user ->
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
if (!user.activeUser) {
|
||||
withBGApi {
|
||||
controller.showProgressIfNeeded {
|
||||
ModalManager.closeAllModalsEverywhere()
|
||||
chatModel.controller.changeActiveUser(user.remoteHostId, user.userId, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onShowAllProfilesClicked = {
|
||||
doWithAuth(
|
||||
generalGetString(MR.strings.auth_open_chat_profiles),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
) {
|
||||
ModalManager.start.showCustomModal { 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 }
|
||||
},
|
||||
content = { UserProfilesView(chatModel, search, profileHidden) })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) {
|
||||
Column {
|
||||
FirstSection()
|
||||
Divider(Modifier.padding(DEFAULT_PADDING))
|
||||
SecondSection()
|
||||
}
|
||||
} else {
|
||||
Row {
|
||||
Box(Modifier.weight(1f)) {
|
||||
FirstSection()
|
||||
}
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(1f)) {
|
||||
SecondSection()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActiveUserSection(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
) {
|
||||
val showCustomModal: (@Composable() (ModalData.(ChatModel, () -> Unit) -> Unit)) -> () -> Unit = { modalView ->
|
||||
{
|
||||
ModalManager.start.showCustomModal { close -> modalView(chatModel, close) }
|
||||
}
|
||||
}
|
||||
val currentUser = remember { chatModel.currentUser }.value
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
|
||||
if (currentUser != null) {
|
||||
SectionView {
|
||||
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
|
||||
ProfilePreview(currentUser.profile, stopped = stopped)
|
||||
}
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_qr_code),
|
||||
if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_simplex_contact_address) else generalGetString(MR.strings.create_simplex_address),
|
||||
@@ -265,9 +225,22 @@ private fun ActiveUserSection(
|
||||
}),
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
} else {
|
||||
SectionView {
|
||||
if (appPlatform.isDesktop) {
|
||||
Divider(Modifier.padding(DEFAULT_PADDING))
|
||||
|
||||
val inactiveUsers = users.filter { !it.user.activeUser }
|
||||
|
||||
if (inactiveUsers.isNotEmpty()) {
|
||||
Column(modifier = Modifier.padding(vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)) {
|
||||
UserPickerUsersSection(
|
||||
users = inactiveUsers,
|
||||
onUserClicked = onUserClicked,
|
||||
stopped = stopped
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chatModel.desktopNoUserNoRemote) {
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
@@ -283,76 +256,121 @@ private fun ActiveUserSection(
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
{
|
||||
doWithAuth(
|
||||
generalGetString(MR.strings.auth_open_chat_profiles),
|
||||
generalGetString(MR.strings.auth_log_in_using_credential)
|
||||
) {
|
||||
ModalManager.start.showCustomModal { 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 }
|
||||
},
|
||||
content = { UserProfilesView(chatModel, search, profileHidden) })
|
||||
}
|
||||
}
|
||||
},
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) {
|
||||
Column {
|
||||
FirstSection()
|
||||
SecondSection()
|
||||
GlobalSettingsSection(
|
||||
userPickerState = userPickerState,
|
||||
setPerformLA = setPerformLA,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column {
|
||||
FirstSection()
|
||||
Row {
|
||||
Box(Modifier.weight(1f)) {
|
||||
Column {
|
||||
SecondSection()
|
||||
}
|
||||
}
|
||||
VerticalDivider()
|
||||
Box(Modifier.weight(1f)) {
|
||||
Column {
|
||||
GlobalSettingsSection(
|
||||
userPickerState = userPickerState,
|
||||
setPerformLA = setPerformLA,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun userPickerAlpha(): Float {
|
||||
return when (CurrentColors.value.base) {
|
||||
DefaultTheme.LIGHT -> 0.05f
|
||||
DefaultTheme.DARK -> 0.05f
|
||||
DefaultTheme.BLACK -> 0.075f
|
||||
DefaultTheme.SIMPLEX -> 0.035f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GlobalSettingsSection(
|
||||
chatModel: ChatModel,
|
||||
userPickerState: MutableStateFlow<AnimatedViewState>,
|
||||
setPerformLA: (Boolean) -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
onShowAllProfilesClicked: () -> Unit
|
||||
) {
|
||||
val stopped = chatModel.chatRunning.value == false
|
||||
val users by remember {
|
||||
derivedStateOf {
|
||||
chatModel.users
|
||||
.filter { u -> !u.user.hidden && !u.user.activeUser }
|
||||
}
|
||||
}
|
||||
val stopped = remember { chatModel.chatRunning }.value == false
|
||||
|
||||
SectionView(headerBottomPadding = if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_PADDING else 0.dp) {
|
||||
UserPickerInactiveUsersSection(
|
||||
users = users,
|
||||
onShowAllProfilesClicked = onShowAllProfilesClicked,
|
||||
onUserClicked = onUserClicked,
|
||||
stopped = stopped
|
||||
)
|
||||
if (appPlatform.isAndroid) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
|
||||
|
||||
if (appPlatform.isAndroid) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
|
||||
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_desktop),
|
||||
text,
|
||||
click = {
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
ConnectDesktopView(close)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
UserPickerOptionRow(
|
||||
icon = painterResource(MR.images.ic_smartphone_300),
|
||||
text = stringResource(if (remember { chat.simplex.common.platform.chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles),
|
||||
click = {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
ModalManager.start.showModal {
|
||||
ConnectMobileView()
|
||||
}
|
||||
},
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
|
||||
SectionItemView(
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_desktop),
|
||||
text,
|
||||
click = {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
SettingsView(chatModel, setPerformLA, close)
|
||||
ModalManager.start.showCustomModal { close ->
|
||||
ConnectDesktopView(close)
|
||||
}
|
||||
}
|
||||
)
|
||||
} else {
|
||||
UserPickerOptionRow(
|
||||
icon = painterResource(MR.images.ic_smartphone_300),
|
||||
text = stringResource(if (remember { chat.simplex.common.platform.chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles),
|
||||
click = {
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
ModalManager.start.showModal {
|
||||
ConnectMobileView()
|
||||
}
|
||||
},
|
||||
padding = PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp)
|
||||
) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary)
|
||||
TextIconSpaced()
|
||||
Text(text, color = Color.Unspecified)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ColorModeSwitcher()
|
||||
}
|
||||
disabled = stopped
|
||||
)
|
||||
}
|
||||
|
||||
SectionItemView(
|
||||
click = {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
SettingsView(chatModel, setPerformLA, close)
|
||||
}
|
||||
},
|
||||
padding = if (appPlatform.isDesktop) PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp) else PaddingValues(start = DEFAULT_PADDING, end = DEFAULT_PADDING_HALF)
|
||||
) {
|
||||
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
|
||||
Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary)
|
||||
TextIconSpaced()
|
||||
Text(text, color = Color.Unspecified)
|
||||
Spacer(Modifier.weight(1f))
|
||||
ColorModeSwitcher()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,7 +378,7 @@ private fun GlobalSettingsSection(
|
||||
fun UserProfilePickerItem(
|
||||
u: User,
|
||||
unreadCount: Int = 0,
|
||||
enabled: Boolean = chatModel.chatRunning.value == true || chatModel.connectedToRemote,
|
||||
enabled: Boolean = remember { chatModel.chatRunning }.value == true || chatModel.connectedToRemote,
|
||||
onLongClick: () -> Unit = {},
|
||||
openSettings: () -> Unit = {},
|
||||
onClick: () -> Unit
|
||||
@@ -409,7 +427,7 @@ fun UserProfilePickerItem(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == true || chatModel.connectedToRemote) {
|
||||
fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning }.value == true || chatModel.connectedToRemote) {
|
||||
Row(
|
||||
Modifier
|
||||
.widthIn(max = windowWidth() * 0.7f)
|
||||
@@ -432,31 +450,14 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr
|
||||
|
||||
@Composable
|
||||
fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false) {
|
||||
SectionItemView(click, disabled = disabled, extraPadding = true) {
|
||||
SectionItemView(click, disabled = disabled, extraPadding = appPlatform.isDesktop) {
|
||||
Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.secondary)
|
||||
TextIconSpaced()
|
||||
Text(text = text, color = if (disabled) MaterialTheme.colors.secondary else Color.Unspecified)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserPickerInactiveUserBadge(userInfo: UserInfo, stopped: Boolean, size: Dp = 60.dp, onClick: (user: User) -> Unit) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { onClick(userInfo.user) },
|
||||
enabled = !stopped
|
||||
) {
|
||||
Box {
|
||||
ProfileImage(size = size, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||
|
||||
if (userInfo.unreadCount > 0) {
|
||||
unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun DevicePickerRow(
|
||||
localDeviceActive: Boolean,
|
||||
@@ -465,13 +466,13 @@ private fun DevicePickerRow(
|
||||
onRemoteHostClick: (rh: RemoteHostInfo, connecting: MutableState<Boolean>) -> Unit,
|
||||
onRemoteHostActionButtonClick: (rh: RemoteHostInfo) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
FlowRow(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
|
||||
.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
|
||||
.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_PADDING + DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
val activeHost = remoteHosts.firstOrNull { h -> h.activeHost }
|
||||
|
||||
@@ -515,10 +516,9 @@ private fun DevicePickerRow(
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun UserPickerInactiveUsersSection(
|
||||
expect fun UserPickerUsersSection(
|
||||
users: List<UserInfo>,
|
||||
stopped: Boolean,
|
||||
onShowAllProfilesClicked: () -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
)
|
||||
|
||||
@@ -554,7 +554,8 @@ fun DevicePill(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp)
|
||||
Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
@@ -567,6 +568,9 @@ fun DevicePill(
|
||||
text,
|
||||
color = MaterialTheme.colors.onSurface,
|
||||
fontSize = 12.sp,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
maxLines = 1,
|
||||
modifier = if (onActionButtonClick != null && actionButtonVisible) Modifier.widthIn(max = 300.dp * fontSizeSqrtMultiplier) else Modifier
|
||||
)
|
||||
if (onActionButtonClick != null && actionButtonVisible) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
@@ -600,14 +604,14 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean) {
|
||||
fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean, hasPadding: Boolean) {
|
||||
Text(
|
||||
if (unreadCount > 0) unreadCountStr(unreadCount) else "",
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
style = TextStyle(textAlign = TextAlign.Center),
|
||||
modifier = Modifier
|
||||
.offset(y = 3.sp.toDp())
|
||||
.offset(y = if (hasPadding) 3.sp.toDp() else -4.sp.toDp(), x = if (hasPadding) 0.dp else 4.sp.toDp())
|
||||
.background(if (userMuted) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
|
||||
.badgeLayout()
|
||||
.padding(horizontal = 2.sp.toDp())
|
||||
@@ -616,7 +620,6 @@ private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean) {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private suspend fun closePicker(userPickerState: MutableStateFlow<AnimatedViewState>) {
|
||||
delay(500)
|
||||
userPickerState.value = AnimatedViewState.HIDING
|
||||
|
||||
@@ -137,9 +137,10 @@ fun ProfileImageForActiveCall(
|
||||
size: Dp,
|
||||
image: String? = null,
|
||||
color: Color = MaterialTheme.colors.secondaryVariant,
|
||||
) {
|
||||
backgroundColor: Color? = null,
|
||||
) {
|
||||
if (image == null) {
|
||||
Box(Modifier.requiredSize(size).clip(CircleShape)) {
|
||||
Box(Modifier.requiredSize(size).clip(CircleShape).then(if (backgroundColor != null) Modifier.background(backgroundColor) else Modifier)) {
|
||||
Icon(
|
||||
AccountCircleFilled,
|
||||
contentDescription = stringResource(MR.strings.icon_descr_profile_image_placeholder),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package chat.simplex.common.views.helpers
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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
|
||||
@@ -9,6 +14,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.res.MR
|
||||
@@ -85,10 +91,12 @@ fun <T> ExposedDropDownSettingWithIcon(
|
||||
values: List<Triple<T, ImageResource, String>>,
|
||||
selection: State<T>,
|
||||
fontSize: TextUnit = 16.sp,
|
||||
iconSize: Dp = 40.dp,
|
||||
iconPaddingPercent: Float = 0.2f,
|
||||
listIconSize: Dp = 30.dp,
|
||||
boxSize: Dp = 60.dp,
|
||||
iconColor: Color = MenuTextColor,
|
||||
enabled: State<Boolean> = mutableStateOf(true),
|
||||
background: Color,
|
||||
minWidth: Dp = 200.dp,
|
||||
onSelected: (T) -> Unit
|
||||
) {
|
||||
@@ -99,13 +107,21 @@ fun <T> ExposedDropDownSettingWithIcon(
|
||||
expanded.value = !expanded.value && enabled.value
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Modifier.padding(start = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.End
|
||||
Box(
|
||||
Modifier
|
||||
.background(background, CircleShape)
|
||||
.size(boxSize)
|
||||
.clickable(
|
||||
onClick = {},
|
||||
role = Role.Button,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = rememberRipple(bounded = false, radius = boxSize / 2, color = background.lighter(0.1f)),
|
||||
enabled = enabled.value
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
val choice = values.first { it.first == selection.value }
|
||||
Icon(painterResource(choice.second), choice.third, Modifier.size(iconSize), tint = iconColor)
|
||||
Icon(painterResource(choice.second), choice.third, Modifier.padding(boxSize * iconPaddingPercent).fillMaxSize(), tint = iconColor)
|
||||
}
|
||||
DefaultExposedDropdownMenu(
|
||||
modifier = Modifier.widthIn(min = minWidth),
|
||||
|
||||
@@ -989,6 +989,7 @@
|
||||
<string name="audio_device_wired_headphones">Headphones</string>
|
||||
<string name="audio_device_bluetooth">Bluetooth</string>
|
||||
<string name="error_initializing_web_view">Error initializing WebView. Update your system to the new version. Please contact developers.\nError: %s</string>
|
||||
<string name="error_initializing_web_view_wrong_arch">Error initializing WebView. Make sure you have WebView installed and it\'s supported architecture is arm64.\nError: %s</string>
|
||||
|
||||
<!-- SimpleXInfo -->
|
||||
<string name="next_generation_of_private_messaging">The next generation\nof private messaging</string>
|
||||
@@ -1075,6 +1076,7 @@
|
||||
<string name="icon_descr_audio_on">Audio on</string>
|
||||
<string name="icon_descr_speaker_off">Speaker off</string>
|
||||
<string name="icon_descr_speaker_on">Speaker on</string>
|
||||
<string name="icon_descr_sound_muted">Sound muted</string>
|
||||
<string name="icon_descr_flip_camera">Flip camera</string>
|
||||
|
||||
<!-- Call items -->
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="m809.5-61.5-133-133q-27 19-58.25 33.25T553.5-139.5V-199q21.5-6.5 42.5-14.5t39.5-22L476-396v229L280-363H122.5v-234H274L54.5-816.5 96-858l755 754-41.5 42.5ZM770-291l-41.5-41.5q20-33 29.75-70.67Q768-440.85 768-481q0-100.82-58.75-180.41T553.5-763v-59.5q120 28 196 123.25t76 218.25q0 50.5-14 98.75T770-291ZM642.5-418.5l-89-89v-132q46.5 21.5 73.75 64.75T654.5-480q0 16-3 31.5t-9 30ZM476-585 372-689l104-104v208Zm-57.5 278v-145.5l-87-87H180v119h124.5l114 113.5ZM375-496Z"/></svg>
|
||||
|
After Width: | Height: | Size: 569 B |
@@ -6,6 +6,15 @@
|
||||
<script src="../lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-screen-video-stream"
|
||||
class="inline"
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
onclick="javascript:toggleRemoteScreenVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
@@ -14,6 +23,7 @@
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
onclick="javascript:toggleRemoteVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
@@ -22,6 +32,15 @@
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-screen-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
></video>
|
||||
</body>
|
||||
<footer>
|
||||
<script src="../call.js"></script>
|
||||
|
||||
@@ -12,6 +12,35 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed {
|
||||
position: absolute;
|
||||
max-width: 30%;
|
||||
max-height: 30%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
bottom: 80px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed-pip {
|
||||
position: absolute;
|
||||
max-width: 50%;
|
||||
max-height: 50%;
|
||||
object-fit: cover;
|
||||
margin: 8px;
|
||||
border-radius: 8px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
@@ -23,6 +52,17 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
max-width: 30%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
top: 30%;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -30,6 +70,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -37,6 +84,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
<script src="/lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-screen-video-stream"
|
||||
class="inline"
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
onclick="javascript:toggleRemoteScreenVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
@@ -14,6 +23,7 @@
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
onclick="javascript:toggleRemoteVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
@@ -21,28 +31,39 @@
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-screen-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
></video>
|
||||
|
||||
<div id="progress"></div>
|
||||
<div id="info-block">
|
||||
<p id="state"></p>
|
||||
<p id="description"></p>
|
||||
<b id="media-sources" style="color: #fff"></b>
|
||||
</div>
|
||||
<div id="audio-call-icon">
|
||||
<img src="/desktop/images/ic_phone_in_talk.svg" />
|
||||
</div>
|
||||
<p id="manage-call">
|
||||
<button id="toggle-screen" style="display: none" onclick="javascript:toggleScreenManually()">
|
||||
<button id="toggle-screen" onclick="javascript:toggleScreenManually()">
|
||||
<img src="/desktop/images/ic_screen_share.svg" />
|
||||
</button>
|
||||
<button id="toggle-audio" style="display: none" onclick="javascript:toggleAudioManually()">
|
||||
<button id="toggle-mic" onclick="javascript:toggleMicManually()">
|
||||
<img src="/desktop/images/ic_mic.svg" />
|
||||
</button>
|
||||
<button id="end-call" onclick="javascript:endCallManually()">
|
||||
<button id="end-call" style="background: red" onclick="javascript:endCallManually()">
|
||||
<img src="/desktop/images/ic_call_end_filled.svg" />
|
||||
</button>
|
||||
<button id="toggle-speaker" style="display: none" onclick="javascript:toggleSpeakerManually()">
|
||||
<button id="toggle-speaker" onclick="javascript:toggleSpeakerManually()">
|
||||
<img src="/desktop/images/ic_volume_up.svg" />
|
||||
</button>
|
||||
<button id="toggle-video" style="display: none" onclick="javascript:toggleVideoManually()">
|
||||
<button id="toggle-camera" onclick="javascript:toggleCameraManually()">
|
||||
<img src="/desktop/images/ic_videocam_off.svg" />
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="red" d="M480 418q125 0 238.75 50.25T914 613.5q8 9.5 8.25 21t-8.25 20L821 748q-8 8-22.5 8.75t-23-5.75l-113-84.5q-6-4.5-8.75-10.25T651 643.5v-139q-42-16-85.5-22.5t-85-6.5q-42 0-85.5 6.5t-85.5 22.5v139q0 6.5-2.75 12.5T298 666.5L184.5 751q-11.5 8.5-23.5 7.5T139.5 748L46 654.5q-8.5-8.5-8.25-20t8.25-21q81.5-95 195.25-145.25T480 418Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M480 418q125 0 238.75 50.25T914 613.5q8 9.5 8.25 21t-8.25 20L821 748q-8 8-22.5 8.75t-23-5.75l-113-84.5q-6-4.5-8.75-10.25T651 643.5v-139q-42-16-85.5-22.5t-85-6.5q-42 0-85.5 6.5t-85.5 22.5v139q0 6.5-2.75 12.5T298 666.5L184.5 751q-11.5 8.5-23.5 7.5T139.5 748L46 654.5q-8.5-8.5-8.25-20t8.25-21q81.5-95 195.25-145.25T480 418Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 435 B After Width: | Height: | Size: 437 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 -960 960 960" width="44" fill="white"><path d="m809.5-61.5-133-133q-27 19-58.25 33.25T553.5-139.5V-199q21.5-6.5 42.5-14.5t39.5-22L476-396v229L280-363H122.5v-234H274L54.5-816.5 96-858l755 754-41.5 42.5ZM770-291l-41.5-41.5q20-33 29.75-70.67Q768-440.85 768-481q0-100.82-58.75-180.41T553.5-763v-59.5q120 28 196 123.25t76 218.25q0 50.5-14 98.75T770-291ZM642.5-418.5l-89-89v-132q46.5 21.5 73.75 64.75T654.5-480q0 16-3 31.5t-9 30ZM476-585 372-689l104-104v208Zm-57.5 278v-145.5l-87-87H180v119h124.5l114 113.5ZM375-496Z"/></svg>
|
||||
|
After Width: | Height: | Size: 582 B |
@@ -12,7 +12,37 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed {
|
||||
position: absolute;
|
||||
max-width: 20%;
|
||||
max-height: 20%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
bottom: 80px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed-pip {
|
||||
position: absolute;
|
||||
max-width: 30%;
|
||||
max-height: 30%;
|
||||
object-fit: cover;
|
||||
margin: 8px;
|
||||
border-radius: 8px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.inline {
|
||||
background-color: black;
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
max-width: 20%;
|
||||
@@ -23,6 +53,17 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
max-width: 20%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
top: 33%;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -30,6 +71,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -37,6 +85,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
@@ -57,19 +112,32 @@ body {
|
||||
#manage-call {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
top: 90%;
|
||||
bottom: 15px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-column-gap: 30px;
|
||||
grid-column-gap: 38px;
|
||||
}
|
||||
|
||||
#manage-call button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-color: inherit;
|
||||
background-color: #ffffff33;
|
||||
border-radius: 50%;
|
||||
padding: 13px;
|
||||
width: 61px;
|
||||
height: 61px;
|
||||
}
|
||||
|
||||
#manage-call img {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
#manage-call button .video {
|
||||
background: #00000033;
|
||||
}
|
||||
|
||||
#progress {
|
||||
@@ -110,7 +178,6 @@ body {
|
||||
#info-block {
|
||||
position: absolute;
|
||||
color: white;
|
||||
line-height: 10px;
|
||||
opacity: 0.8;
|
||||
width: 200px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
|
||||
@@ -9,6 +9,7 @@ socket.addEventListener("open", (_event) => {
|
||||
sendMessageToNative = (msg) => {
|
||||
console.log("Message to server");
|
||||
socket.send(JSON.stringify(msg));
|
||||
reactOnMessageToServer(msg);
|
||||
};
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
@@ -27,71 +28,156 @@ socket.addEventListener("close", (_event) => {
|
||||
function endCallManually() {
|
||||
sendMessageToNative({ resp: { type: "end" } });
|
||||
}
|
||||
function toggleAudioManually() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) {
|
||||
document.getElementById("toggle-audio").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio)
|
||||
? '<img src="/desktop/images/ic_mic.svg" />'
|
||||
: '<img src="/desktop/images/ic_mic_off.svg" />';
|
||||
}
|
||||
function toggleMicManually() {
|
||||
const enable = activeCall ? !activeCall.localMediaSources.mic : !inactiveCallMediaSources.mic;
|
||||
const apiCall = {
|
||||
command: { type: "media", source: CallMediaSource.Mic, enable: enable },
|
||||
};
|
||||
processCommand(apiCall);
|
||||
}
|
||||
function toggleSpeakerManually() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) {
|
||||
document.getElementById("toggle-speaker").innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio)
|
||||
? '<img src="/desktop/images/ic_volume_up.svg" />'
|
||||
: '<img src="/desktop/images/ic_volume_down.svg" />';
|
||||
if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) && activeCall.peerMediaSources.mic) {
|
||||
enableSpeakerIcon(togglePeerMedia(activeCall.remoteStream, CallMediaType.Audio), !activeCall.peerMediaSources.mic);
|
||||
}
|
||||
}
|
||||
function toggleVideoManually() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) {
|
||||
let res;
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.screenShareEnabled) {
|
||||
activeCall.cameraEnabled = !activeCall.cameraEnabled;
|
||||
res = activeCall.cameraEnabled;
|
||||
}
|
||||
else {
|
||||
res = toggleMedia(activeCall.localStream, CallMediaType.Video);
|
||||
}
|
||||
document.getElementById("toggle-video").innerHTML = res
|
||||
? '<img src="/desktop/images/ic_videocam_filled.svg" />'
|
||||
: '<img src="/desktop/images/ic_videocam_off.svg" />';
|
||||
}
|
||||
function toggleCameraManually() {
|
||||
const enable = activeCall ? !activeCall.localMediaSources.camera : !inactiveCallMediaSources.camera;
|
||||
const apiCall = {
|
||||
command: { type: "media", source: CallMediaSource.Camera, enable: enable },
|
||||
};
|
||||
processCommand(apiCall);
|
||||
}
|
||||
async function toggleScreenManually() {
|
||||
const was = activeCall === null || activeCall === void 0 ? void 0 : activeCall.screenShareEnabled;
|
||||
await toggleScreenShare();
|
||||
if (was != (activeCall === null || activeCall === void 0 ? void 0 : activeCall.screenShareEnabled)) {
|
||||
document.getElementById("toggle-screen").innerHTML = (activeCall === null || activeCall === void 0 ? void 0 : activeCall.screenShareEnabled)
|
||||
? '<img src="/desktop/images/ic_stop_screen_share.svg" />'
|
||||
: '<img src="/desktop/images/ic_screen_share.svg" />';
|
||||
}
|
||||
// override function in call.ts to adapt UI to enabled media sources
|
||||
localOrPeerMediaSourcesChanged = (call) => {
|
||||
enableMicIcon(call.localMediaSources.mic);
|
||||
enableCameraIcon(call.localMediaSources.camera);
|
||||
enableScreenIcon(call.localMediaSources.screenVideo);
|
||||
const className = localMedia(call) == CallMediaType.Video || peerMedia(call) == CallMediaType.Video ? CallMediaType.Video : CallMediaType.Audio;
|
||||
document.getElementById("info-block").className = className;
|
||||
if (call.connection.iceConnectionState == "connected") {
|
||||
document.getElementById("audio-call-icon").style.display = className == CallMediaType.Audio ? "block" : "none";
|
||||
}
|
||||
// document.getElementById("media-sources")!.innerText = mediaSourcesStatus(call)
|
||||
document.getElementById("manage-call").className = localMedia(call) == CallMediaType.Video ? CallMediaType.Video : "";
|
||||
};
|
||||
// override function in call.ts to adapt UI to enabled media sources
|
||||
inactiveCallMediaSourcesChanged = (inactiveCallMediaSources) => {
|
||||
const mic = inactiveCallMediaSources.mic;
|
||||
const camera = inactiveCallMediaSources.camera;
|
||||
const screenVideo = inactiveCallMediaSources.screenVideo;
|
||||
enableMicIcon(mic);
|
||||
enableCameraIcon(camera);
|
||||
enableScreenIcon(screenVideo);
|
||||
const className = camera ? CallMediaType.Video : CallMediaType.Audio;
|
||||
document.getElementById("info-block").className = className;
|
||||
// document.getElementById("media-sources")!.innerText = inactiveCallMediaSourcesStatus(inactiveCallMediaSources)
|
||||
};
|
||||
function enableMicIcon(enabled) {
|
||||
document.getElementById("toggle-mic").innerHTML = enabled
|
||||
? '<img src="/desktop/images/ic_mic.svg" />'
|
||||
: '<img src="/desktop/images/ic_mic_off.svg" />';
|
||||
}
|
||||
function enableCameraIcon(enabled) {
|
||||
document.getElementById("toggle-camera").innerHTML = enabled
|
||||
? '<img src="/desktop/images/ic_videocam_filled.svg" />'
|
||||
: '<img src="/desktop/images/ic_videocam_off.svg" />';
|
||||
}
|
||||
function enableScreenIcon(enabled) {
|
||||
document.getElementById("toggle-screen").innerHTML = enabled
|
||||
? '<img src="/desktop/images/ic_stop_screen_share.svg" />'
|
||||
: '<img src="/desktop/images/ic_screen_share.svg" />';
|
||||
}
|
||||
function enableSpeakerIcon(enabled, muted) {
|
||||
document.getElementById("toggle-speaker").innerHTML = muted
|
||||
? '<img src="/desktop/images/ic_volume_off.svg" />'
|
||||
: enabled
|
||||
? '<img src="/desktop/images/ic_volume_up.svg" />'
|
||||
: '<img src="/desktop/images/ic_volume_down.svg" />';
|
||||
document.getElementById("toggle-speaker").style.opacity = muted ? "0.7" : "1";
|
||||
}
|
||||
function mediaSourcesStatus(call) {
|
||||
let status = "local";
|
||||
if (call.localMediaSources.mic)
|
||||
status += " mic";
|
||||
if (call.localMediaSources.camera)
|
||||
status += " cam";
|
||||
if (call.localMediaSources.screenAudio)
|
||||
status += " scrA";
|
||||
if (call.localMediaSources.screenVideo)
|
||||
status += " scrV";
|
||||
status += " | peer";
|
||||
if (call.peerMediaSources.mic)
|
||||
status += " mic";
|
||||
if (call.peerMediaSources.camera)
|
||||
status += " cam";
|
||||
if (call.peerMediaSources.screenAudio)
|
||||
status += " scrA";
|
||||
if (call.peerMediaSources.screenVideo)
|
||||
status += " scrV";
|
||||
return status;
|
||||
}
|
||||
function inactiveCallMediaSourcesStatus(inactiveCallMediaSources) {
|
||||
let status = "local";
|
||||
const mic = inactiveCallMediaSources.mic;
|
||||
const camera = inactiveCallMediaSources.camera;
|
||||
const screenAudio = inactiveCallMediaSources.screenAudio;
|
||||
const screenVideo = inactiveCallMediaSources.screenVideo;
|
||||
if (mic)
|
||||
status += " mic";
|
||||
if (camera)
|
||||
status += " cam";
|
||||
if (screenAudio)
|
||||
status += " scrA";
|
||||
if (screenVideo)
|
||||
status += " scrV";
|
||||
return status;
|
||||
}
|
||||
function reactOnMessageFromServer(msg) {
|
||||
var _a;
|
||||
switch ((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) {
|
||||
var _a, _b, _c;
|
||||
// screen is not allowed to be enabled before connection estabilished
|
||||
if (((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) == "capabilities" || ((_b = msg.command) === null || _b === void 0 ? void 0 : _b.type) == "offer") {
|
||||
document.getElementById("toggle-screen").style.opacity = "0.7";
|
||||
}
|
||||
else if (activeCall) {
|
||||
document.getElementById("toggle-screen").style.opacity = "1";
|
||||
}
|
||||
switch ((_c = msg.command) === null || _c === void 0 ? void 0 : _c.type) {
|
||||
case "capabilities":
|
||||
document.getElementById("info-block").className = msg.command.media;
|
||||
break;
|
||||
case "offer":
|
||||
case "start":
|
||||
document.getElementById("toggle-audio").style.display = "inline-block";
|
||||
document.getElementById("toggle-speaker").style.display = "inline-block";
|
||||
if (msg.command.media == CallMediaType.Video) {
|
||||
document.getElementById("toggle-video").style.display = "inline-block";
|
||||
document.getElementById("toggle-screen").style.display = "inline-block";
|
||||
}
|
||||
document.getElementById("info-block").className = msg.command.media;
|
||||
document.getElementById("toggle-mic").style.display = "inline-block";
|
||||
document.getElementById("toggle-speaker").style.display = "inline-block";
|
||||
document.getElementById("toggle-camera").style.display = "inline-block";
|
||||
document.getElementById("toggle-screen").style.display = "inline-block";
|
||||
enableSpeakerIcon(true, true);
|
||||
break;
|
||||
case "description":
|
||||
updateCallInfoView(msg.command.state, msg.command.description);
|
||||
if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection.connectionState) == "connected") {
|
||||
document.getElementById("progress").style.display = "none";
|
||||
if (document.getElementById("info-block").className == CallMediaType.Audio) {
|
||||
document.getElementById("audio-call-icon").style.display = "block";
|
||||
}
|
||||
document.getElementById("audio-call-icon").style.display =
|
||||
document.getElementById("info-block").className == CallMediaType.Audio ? "block" : "none";
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
function reactOnMessageToServer(msg) {
|
||||
var _a;
|
||||
if (!activeCall)
|
||||
return;
|
||||
switch ((_a = msg.resp) === null || _a === void 0 ? void 0 : _a.type) {
|
||||
case "peerMedia":
|
||||
const className = localMedia(activeCall) == CallMediaType.Video || peerMedia(activeCall) == CallMediaType.Video ? "video" : "audio";
|
||||
document.getElementById("info-block").className = className;
|
||||
document.getElementById("audio-call-icon").style.display = className == CallMediaType.Audio ? "block" : "none";
|
||||
enableSpeakerIcon(activeCall.remoteStream.getAudioTracks().every((elem) => elem.enabled), !activeCall.peerMediaSources.mic);
|
||||
break;
|
||||
}
|
||||
}
|
||||
function updateCallInfoView(state, description) {
|
||||
document.getElementById("state").innerText = state;
|
||||
document.getElementById("description").innerText = description;
|
||||
|
||||
@@ -1 +1 @@
|
||||
var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;n<o.length;n++)t[o][o.charAt(n)]=n}return t[o][r]}var r=String.fromCharCode,n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",t={},i={compressToBase64:function(o){if(null==o)return"";var r=i._compress(o,6,function(o){return n.charAt(o)});switch(r.length%4){default:case 0:return r;case 1:return r+"===";case 2:return r+"==";case 3:return r+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(e){return o(n,r.charAt(e))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(o){return null==o?"":""==o?null:i._decompress(o.length,16384,function(r){return o.charCodeAt(r)-32})},compressToUint8Array:function(o){for(var r=i.compress(o),n=new Uint8Array(2*r.length),e=0,t=r.length;t>e;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<o.length;i+=1)if(u=o.charAt(i),Object.prototype.hasOwnProperty.call(s,u)||(s[u]=f++,p[u]=!0),c=a+u,Object.prototype.hasOwnProperty.call(s,c))a=c;else{if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);
|
||||
var LZString=function(){var r=String.fromCharCode,o="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",e={};function t(r,o){if(!e[r]){e[r]={};for(var n=0;n<r.length;n++)e[r][r.charAt(n)]=n}return e[r][o]}var i={compressToBase64:function(r){if(null==r)return"";var n=i._compress(r,6,function(r){return o.charAt(r)});switch(n.length%4){default:case 0:return n;case 1:return n+"===";case 2:return n+"==";case 3:return n+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(n){return t(o,r.charAt(n))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(r){return null==r?"":""==r?null:i._decompress(r.length,16384,function(o){return r.charCodeAt(o)-32})},compressToUint8Array:function(r){for(var o=i.compress(r),n=new Uint8Array(2*o.length),e=0,t=o.length;e<t;e++){var s=o.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null==o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;e<t;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(r){return null==r?"":i._compress(r,6,function(r){return n.charAt(r)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(o){return t(n,r.charAt(o))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(r,o,n){if(null==r)return"";var e,t,i,s={},u={},a="",p="",c="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<r.length;i+=1)if(a=r.charAt(i),Object.prototype.hasOwnProperty.call(s,a)||(s[a]=f++,u[a]=!0),p=c+a,Object.prototype.hasOwnProperty.call(s,p))c=p;else{if(Object.prototype.hasOwnProperty.call(u,c)){if(c.charCodeAt(0)<256){for(e=0;e<h;e++)m<<=1,v==o-1?(v=0,d.push(n(m)),m=0):v++;for(t=c.charCodeAt(0),e=0;e<8;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;e<h;e++)m=m<<1|t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=c.charCodeAt(0),e=0;e<16;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}0==--l&&(l=Math.pow(2,h),h++),delete u[c]}else for(t=s[c],e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;0==--l&&(l=Math.pow(2,h),h++),s[p]=f++,c=String(a)}if(""!==c){if(Object.prototype.hasOwnProperty.call(u,c)){if(c.charCodeAt(0)<256){for(e=0;e<h;e++)m<<=1,v==o-1?(v=0,d.push(n(m)),m=0):v++;for(t=c.charCodeAt(0),e=0;e<8;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;e<h;e++)m=m<<1|t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=c.charCodeAt(0),e=0;e<16;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}0==--l&&(l=Math.pow(2,h),h++),delete u[c]}else for(t=s[c],e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;0==--l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;e<h;e++)m=m<<1|1&t,v==o-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==o-1){d.push(n(m));break}v++}return d.join("")},decompress:function(r){return null==r?"":""==r?null:i._decompress(r.length,32768,function(o){return r.charCodeAt(o)})},_decompress:function(o,n,e){var t,i,s,u,a,p,c,l=[],f=4,h=4,d=3,m="",v=[],g={val:e(0),position:n,index:1};for(t=0;t<3;t+=1)l[t]=t;for(s=0,a=Math.pow(2,2),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;switch(s){case 0:for(s=0,a=Math.pow(2,8),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;c=r(s);break;case 1:for(s=0,a=Math.pow(2,16),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;c=r(s);break;case 2:return""}for(l[3]=c,i=c,v.push(c);;){if(g.index>o)return"";for(s=0,a=Math.pow(2,d),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;switch(c=s){case 0:for(s=0,a=Math.pow(2,8),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;l[h++]=r(s),c=h-1,f--;break;case 1:for(s=0,a=Math.pow(2,16),p=1;p!=a;)u=g.val&g.position,g.position>>=1,0==g.position&&(g.position=n,g.val=e(g.index++)),s|=(u>0?1:0)*p,p<<=1;l[h++]=r(s),c=h-1,f--;break;case 2:return v.join("")}if(0==f&&(f=Math.pow(2,d),d++),l[c])m=l[c];else{if(c!==h)return null;m=i+i.charAt(0)}v.push(m),l[h++]=i+m.charAt(0),i=m,0==--f&&(f=Math.pow(2,d),d++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module?module.exports=LZString:"undefined"!=typeof angular&&null!=angular&&angular.module("LZString",[]).factory("LZString",function(){return LZString});
|
||||
@@ -34,14 +34,14 @@ actual fun ActiveCallView() {
|
||||
val callRh = call.remoteHostId
|
||||
when (val r = apiMsg.resp) {
|
||||
is WCallResponse.Capabilities -> withBGApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
val callType = CallType(call.initialCallType, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||
CallSoundsPlayer.startConnectingCallSound(scope)
|
||||
activeCallWaitDeliveryReceipt(scope)
|
||||
}
|
||||
is WCallResponse.Offer -> withBGApi {
|
||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.initialCallType, r.capabilities)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Answer -> withBGApi {
|
||||
@@ -65,6 +65,15 @@ actual fun ActiveCallView() {
|
||||
is WCallResponse.Connected -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||
}
|
||||
is WCallResponse.PeerMedia -> {
|
||||
val sources = call.peerMediaSources
|
||||
chatModel.activeCall.value = when (r.source) {
|
||||
CallMediaSource.Mic -> call.copy(peerMediaSources = sources.copy(mic = r.enabled))
|
||||
CallMediaSource.Camera -> call.copy(peerMediaSources = sources.copy(camera = r.enabled))
|
||||
CallMediaSource.ScreenAudio -> call.copy(peerMediaSources = sources.copy(screenAudio = r.enabled))
|
||||
CallMediaSource.ScreenVideo -> call.copy(peerMediaSources = sources.copy(screenVideo = r.enabled))
|
||||
}
|
||||
}
|
||||
is WCallResponse.End -> {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
@@ -77,15 +86,18 @@ actual fun ActiveCallView() {
|
||||
is WCallCommand.Answer ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
is WCallCommand.Media -> {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
||||
val sources = call.localMediaSources
|
||||
when (cmd.source) {
|
||||
CallMediaSource.Mic -> chatModel.activeCall.value = call.copy(localMediaSources = sources.copy(mic = cmd.enable))
|
||||
CallMediaSource.Camera -> chatModel.activeCall.value = call.copy(localMediaSources = sources.copy(camera = cmd.enable))
|
||||
CallMediaSource.ScreenAudio -> chatModel.activeCall.value = call.copy(localMediaSources = sources.copy(screenAudio = cmd.enable))
|
||||
CallMediaSource.ScreenVideo -> chatModel.activeCall.value = call.copy(localMediaSources = sources.copy(screenVideo = cmd.enable))
|
||||
}
|
||||
}
|
||||
is WCallCommand.Camera -> {
|
||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||
if (!call.localMediaSources.mic) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaSource.Mic, enable = false))
|
||||
}
|
||||
}
|
||||
is WCallCommand.End ->
|
||||
|
||||
@@ -24,7 +24,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@Composable
|
||||
actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val media = call.peerMedia ?: call.localMedia
|
||||
CompositionLocalProvider(
|
||||
LocalIndication provides NoIndication
|
||||
) {
|
||||
@@ -56,7 +55,7 @@ actual fun ActiveCallInteractiveArea(call: Call) {
|
||||
Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
if (media == CallMediaType.Video) {
|
||||
if (call.hasVideo) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_videocam_filled),
|
||||
stringResource(MR.strings.icon_descr_video_call),
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
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.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
actual fun UserPickerInactiveUsersSection(
|
||||
actual fun UserPickerUsersSection(
|
||||
users: List<UserInfo>,
|
||||
stopped: Boolean,
|
||||
onShowAllProfilesClicked: () -> Unit,
|
||||
onUserClicked: (user: User) -> Unit,
|
||||
) {
|
||||
if (users.isNotEmpty()) {
|
||||
@@ -34,13 +34,13 @@ actual fun UserPickerInactiveUsersSection(
|
||||
val horizontalPadding = DEFAULT_PADDING_HALF + 8.dp
|
||||
|
||||
Column(Modifier
|
||||
.padding(horizontal = horizontalPadding, vertical = DEFAULT_PADDING_HALF)
|
||||
.height(55.dp * rowsToDisplay + (if (rowsToDisplay > 1) DEFAULT_PADDING else 0.dp))
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.height((55.dp + 16.sp.toDp()) * rowsToDisplay + (if (rowsToDisplay > 1) DEFAULT_PADDING else 0.dp))
|
||||
) {
|
||||
ColumnWithScrollBar(
|
||||
verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING)
|
||||
) {
|
||||
val spaceBetween = (((DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - (horizontalPadding)) - (55.dp * 5)) / 5
|
||||
val spaceBetween = (((DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - (horizontalPadding)) - (65.dp * 5)) / 5
|
||||
|
||||
userRows.forEach { row ->
|
||||
Row(
|
||||
@@ -48,8 +48,31 @@ actual fun UserPickerInactiveUsersSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(spaceBetween),
|
||||
) {
|
||||
row.forEach { u ->
|
||||
UserPickerInactiveUserBadge(u, stopped, size = 55.dp) {
|
||||
onUserClicked(u.user)
|
||||
Column(modifier = Modifier
|
||||
.clickable (
|
||||
onClick = { onUserClicked(u.user) },
|
||||
enabled = !stopped
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
val user = u.user
|
||||
Box {
|
||||
ProfileImage(size = 55.dp, image = user.profile.image, color = MaterialTheme.colors.secondaryVariant)
|
||||
|
||||
if (u.unreadCount > 0 && !user.activeUser) {
|
||||
unreadBadge(u.unreadCount, user.showNtfs, true)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
user.displayName,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.width(65.dp),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,12 +80,6 @@ actual fun UserPickerInactiveUsersSection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UserPickerOptionRow(
|
||||
painterResource(MR.images.ic_manage_accounts),
|
||||
stringResource(MR.strings.your_chat_profiles),
|
||||
onShowAllProfilesClicked
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -83,4 +100,4 @@ actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,11 @@ android.enableJetifier=true
|
||||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||
kotlin.jvm.target=11
|
||||
|
||||
android.version_name=6.1-beta.1
|
||||
android.version_code=240
|
||||
android.version_name=6.1-beta.3
|
||||
android.version_code=244
|
||||
|
||||
desktop.version_name=6.1-beta.1
|
||||
desktop.version_code=67
|
||||
desktop.version_name=6.1-beta.3
|
||||
desktop.version_code=70
|
||||
|
||||
kotlin.version=1.9.23
|
||||
gradle.plugin.version=8.2.0
|
||||
|
||||
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 309ef3766cc6b69e0c3aa0c140faab25383b732a
|
||||
tag: da79d544cf990fb0638ae5434407d6a30724fb87
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
@@ -49,3 +49,17 @@ source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/zip.git
|
||||
tag: bd421c6b19cc4c465cd7af1f6f26169fb8ee1ebc
|
||||
|
||||
-- waiting for published warp-tls-3.4.7
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/yesodweb/wai.git
|
||||
tag: ec5e017d896a78e787a5acea62b37a4e677dec2e
|
||||
subdir: warp-tls
|
||||
|
||||
-- backported fork due http-5.0
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/wai.git
|
||||
tag: 2f6e5aa5f05ba9140ac99e195ee647b4f7d926b0
|
||||
subdir: warp
|
||||
|
||||
@@ -148,14 +148,20 @@ Check battery settings for the app - it should be set to Unrestricted.
|
||||
|
||||
For some devices, there may be additional options to prevent the app from being killed - e.g., on Xiaomi you need to enable Auto Start setting for the app. Please consult https://dontkillmyapp.com site for any additional settings for your device.
|
||||
|
||||
**iOS notifications failed to initialize correctly**
|
||||
**Why my notifications aren't working on iOS**
|
||||
|
||||
Check the color of the bolt icon next to Notifications in app settings - it should be green.
|
||||
|
||||
If it's not, please open notifications, disable them (choose Off / Local), and then enable again - you should do it when you have Internet connection.
|
||||
|
||||
Check if your push server has been restarted at time of the issue (Notifications -> Push server) at https://status.simplex.chat if it has been restarted, you may not receive notifications from that time.
|
||||
|
||||
If device was offline, you may need to open the app to start receiving notifications.
|
||||
|
||||
If the above didn't help, the reason could be that iOS failed to issue notification token - we have seen this issue several times. In this case, restarting the whole device should help.
|
||||
|
||||
In some cases notifications may still not work, iOS notifications are hard to do right in a decentralized app, we will be improving them soon to be more reliable.
|
||||
|
||||
**Messaging server or notification server is under maintenance**
|
||||
|
||||
Please check the current status of preset servers at [https://status.simplex.chat](https://status.simplex.chat). You can also connect to status bot via QR code on that page - it will send the updates when the server is offline for maintenance, and also when the new versions of the app are released.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 6.1.0.3
|
||||
version: 6.1.0.5
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"author": "SimpleX Chat",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"devDependencies": {
|
||||
"@types/lz-string": "^1.3.34",
|
||||
"@types/lz-string": "1.3.34",
|
||||
"husky": "^7.0.4",
|
||||
"isomorphic-webcrypto": "^2.3.8",
|
||||
"lint-staged": "^12.4.1",
|
||||
@@ -38,6 +38,6 @@
|
||||
"**/*": "prettier --write --ignore-unknown"
|
||||
},
|
||||
"dependencies": {
|
||||
"lz-string": "^1.4.4"
|
||||
"lz-string": "1.5.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
<script src="../lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-screen-video-stream"
|
||||
class="inline"
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
onclick="javascript:toggleRemoteScreenVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
@@ -14,6 +23,7 @@
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
onclick="javascript:toggleRemoteVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
@@ -22,6 +32,15 @@
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-screen-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
></video>
|
||||
</body>
|
||||
<footer>
|
||||
<script src="../call.js"></script>
|
||||
|
||||
@@ -12,6 +12,35 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed {
|
||||
position: absolute;
|
||||
max-width: 30%;
|
||||
max-height: 30%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
bottom: 80px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed-pip {
|
||||
position: absolute;
|
||||
max-width: 50%;
|
||||
max-height: 50%;
|
||||
object-fit: cover;
|
||||
margin: 8px;
|
||||
border-radius: 8px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
@@ -23,6 +52,17 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
max-width: 30%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
top: 30%;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -30,6 +70,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -37,6 +84,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
<script src="/lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-screen-video-stream"
|
||||
class="inline"
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
onclick="javascript:toggleRemoteScreenVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
class="inline"
|
||||
@@ -14,6 +23,7 @@
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
onclick="javascript:toggleRemoteVideoFitFill()"
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-video-stream"
|
||||
class="inline"
|
||||
@@ -21,28 +31,39 @@
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
|
||||
<video
|
||||
id="local-screen-video-stream"
|
||||
class="inline"
|
||||
muted
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
style="visibility: hidden"
|
||||
></video>
|
||||
|
||||
<div id="progress"></div>
|
||||
<div id="info-block">
|
||||
<p id="state"></p>
|
||||
<p id="description"></p>
|
||||
<b id="media-sources" style="color: #fff"></b>
|
||||
</div>
|
||||
<div id="audio-call-icon">
|
||||
<img src="/desktop/images/ic_phone_in_talk.svg" />
|
||||
</div>
|
||||
<p id="manage-call">
|
||||
<button id="toggle-screen" style="display: none" onclick="javascript:toggleScreenManually()">
|
||||
<button id="toggle-screen" onclick="javascript:toggleScreenManually()">
|
||||
<img src="/desktop/images/ic_screen_share.svg" />
|
||||
</button>
|
||||
<button id="toggle-audio" style="display: none" onclick="javascript:toggleAudioManually()">
|
||||
<button id="toggle-mic" onclick="javascript:toggleMicManually()">
|
||||
<img src="/desktop/images/ic_mic.svg" />
|
||||
</button>
|
||||
<button id="end-call" onclick="javascript:endCallManually()">
|
||||
<button id="end-call" style="background: red" onclick="javascript:endCallManually()">
|
||||
<img src="/desktop/images/ic_call_end_filled.svg" />
|
||||
</button>
|
||||
<button id="toggle-speaker" style="display: none" onclick="javascript:toggleSpeakerManually()">
|
||||
<button id="toggle-speaker" onclick="javascript:toggleSpeakerManually()">
|
||||
<img src="/desktop/images/ic_volume_up.svg" />
|
||||
</button>
|
||||
<button id="toggle-video" style="display: none" onclick="javascript:toggleVideoManually()">
|
||||
<button id="toggle-camera" onclick="javascript:toggleCameraManually()">
|
||||
<img src="/desktop/images/ic_videocam_off.svg" />
|
||||
</button>
|
||||
</p>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="red" d="M480 418q125 0 238.75 50.25T914 613.5q8 9.5 8.25 21t-8.25 20L821 748q-8 8-22.5 8.75t-23-5.75l-113-84.5q-6-4.5-8.75-10.25T651 643.5v-139q-42-16-85.5-22.5t-85-6.5q-42 0-85.5 6.5t-85.5 22.5v139q0 6.5-2.75 12.5T298 666.5L184.5 751q-11.5 8.5-23.5 7.5T139.5 748L46 654.5q-8.5-8.5-8.25-20t8.25-21q81.5-95 195.25-145.25T480 418Z"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M480 418q125 0 238.75 50.25T914 613.5q8 9.5 8.25 21t-8.25 20L821 748q-8 8-22.5 8.75t-23-5.75l-113-84.5q-6-4.5-8.75-10.25T651 643.5v-139q-42-16-85.5-22.5t-85-6.5q-42 0-85.5 6.5t-85.5 22.5v139q0 6.5-2.75 12.5T298 666.5L184.5 751q-11.5 8.5-23.5 7.5T139.5 748L46 654.5q-8.5-8.5-8.25-20t8.25-21q81.5-95 195.25-145.25T480 418Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 435 B After Width: | Height: | Size: 437 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 -960 960 960" width="44" fill="white"><path d="m809.5-61.5-133-133q-27 19-58.25 33.25T553.5-139.5V-199q21.5-6.5 42.5-14.5t39.5-22L476-396v229L280-363H122.5v-234H274L54.5-816.5 96-858l755 754-41.5 42.5ZM770-291l-41.5-41.5q20-33 29.75-70.67Q768-440.85 768-481q0-100.82-58.75-180.41T553.5-763v-59.5q120 28 196 123.25t76 218.25q0 50.5-14 98.75T770-291ZM642.5-418.5l-89-89v-132q46.5 21.5 73.75 64.75T654.5-480q0 16-3 31.5t-9 30ZM476-585 372-689l104-104v208Zm-57.5 278v-145.5l-87-87H180v119h124.5l114 113.5ZM375-496Z"/></svg>
|
||||
|
After Width: | Height: | Size: 582 B |
@@ -12,7 +12,37 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed {
|
||||
position: absolute;
|
||||
max-width: 20%;
|
||||
max-height: 20%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
bottom: 80px;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.collapsed-pip {
|
||||
position: absolute;
|
||||
max-width: 30%;
|
||||
max-height: 30%;
|
||||
object-fit: cover;
|
||||
margin: 8px;
|
||||
border-radius: 8px;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.inline {
|
||||
background-color: black;
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
max-width: 20%;
|
||||
@@ -23,6 +53,17 @@ body {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.inline {
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
max-width: 20%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
top: 33%;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#remote-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -30,6 +71,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#remote-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
@@ -37,6 +85,13 @@ body {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-screen-video-stream.fullscreen {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
@@ -57,19 +112,32 @@ body {
|
||||
#manage-call {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
top: 90%;
|
||||
bottom: 15px;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-column-gap: 30px;
|
||||
grid-column-gap: 38px;
|
||||
}
|
||||
|
||||
#manage-call button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-color: inherit;
|
||||
background-color: #ffffff33;
|
||||
border-radius: 50%;
|
||||
padding: 13px;
|
||||
width: 61px;
|
||||
height: 61px;
|
||||
}
|
||||
|
||||
#manage-call img {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
#manage-call button .video {
|
||||
background: #00000033;
|
||||
}
|
||||
|
||||
#progress {
|
||||
@@ -110,7 +178,6 @@ body {
|
||||
#info-block {
|
||||
position: absolute;
|
||||
color: white;
|
||||
line-height: 10px;
|
||||
opacity: 0.8;
|
||||
width: 200px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
|
||||
@@ -10,6 +10,7 @@ socket.addEventListener("open", (_event) => {
|
||||
sendMessageToNative = (msg: WVApiMessage) => {
|
||||
console.log("Message to server")
|
||||
socket.send(JSON.stringify(msg))
|
||||
reactOnMessageToServer(msg)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -32,74 +33,165 @@ function endCallManually() {
|
||||
sendMessageToNative({resp: {type: "end"}})
|
||||
}
|
||||
|
||||
function toggleAudioManually() {
|
||||
if (activeCall?.localMedia) {
|
||||
document.getElementById("toggle-audio")!!.innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio)
|
||||
? '<img src="/desktop/images/ic_mic.svg" />'
|
||||
: '<img src="/desktop/images/ic_mic_off.svg" />'
|
||||
function toggleMicManually() {
|
||||
const enable = activeCall ? !activeCall.localMediaSources.mic : !inactiveCallMediaSources.mic
|
||||
const apiCall: WVAPICall = {
|
||||
command: {type: "media", source: CallMediaSource.Mic, enable: enable},
|
||||
}
|
||||
processCommand(apiCall)
|
||||
}
|
||||
|
||||
function toggleSpeakerManually() {
|
||||
if (activeCall?.remoteStream) {
|
||||
document.getElementById("toggle-speaker")!!.innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio)
|
||||
? '<img src="/desktop/images/ic_volume_up.svg" />'
|
||||
: '<img src="/desktop/images/ic_volume_down.svg" />'
|
||||
if (activeCall?.remoteStream && activeCall.peerMediaSources.mic) {
|
||||
enableSpeakerIcon(togglePeerMedia(activeCall.remoteStream, CallMediaType.Audio), !activeCall.peerMediaSources.mic)
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVideoManually() {
|
||||
if (activeCall?.localMedia) {
|
||||
let res: boolean
|
||||
if (activeCall?.screenShareEnabled) {
|
||||
activeCall.cameraEnabled = !activeCall.cameraEnabled
|
||||
res = activeCall.cameraEnabled
|
||||
} else {
|
||||
res = toggleMedia(activeCall.localStream, CallMediaType.Video)
|
||||
}
|
||||
document.getElementById("toggle-video")!!.innerHTML = res
|
||||
? '<img src="/desktop/images/ic_videocam_filled.svg" />'
|
||||
: '<img src="/desktop/images/ic_videocam_off.svg" />'
|
||||
function toggleCameraManually() {
|
||||
const enable = activeCall ? !activeCall.localMediaSources.camera : !inactiveCallMediaSources.camera
|
||||
const apiCall: WVAPICall = {
|
||||
command: {type: "media", source: CallMediaSource.Camera, enable: enable},
|
||||
}
|
||||
processCommand(apiCall)
|
||||
}
|
||||
|
||||
async function toggleScreenManually() {
|
||||
const was = activeCall?.screenShareEnabled
|
||||
await toggleScreenShare()
|
||||
if (was != activeCall?.screenShareEnabled) {
|
||||
document.getElementById("toggle-screen")!!.innerHTML = activeCall?.screenShareEnabled
|
||||
? '<img src="/desktop/images/ic_stop_screen_share.svg" />'
|
||||
: '<img src="/desktop/images/ic_screen_share.svg" />'
|
||||
}
|
||||
|
||||
// override function in call.ts to adapt UI to enabled media sources
|
||||
localOrPeerMediaSourcesChanged = (call: Call) => {
|
||||
enableMicIcon(call.localMediaSources.mic)
|
||||
enableCameraIcon(call.localMediaSources.camera)
|
||||
enableScreenIcon(call.localMediaSources.screenVideo)
|
||||
|
||||
const className =
|
||||
localMedia(call) == CallMediaType.Video || peerMedia(call) == CallMediaType.Video ? CallMediaType.Video : CallMediaType.Audio
|
||||
document.getElementById("info-block")!.className = className
|
||||
|
||||
if (call.connection.iceConnectionState == "connected") {
|
||||
document.getElementById("audio-call-icon")!.style.display = className == CallMediaType.Audio ? "block" : "none"
|
||||
}
|
||||
|
||||
// document.getElementById("media-sources")!.innerText = mediaSourcesStatus(call)
|
||||
document.getElementById("manage-call")!.className = localMedia(call) == CallMediaType.Video ? CallMediaType.Video : ""
|
||||
}
|
||||
|
||||
// override function in call.ts to adapt UI to enabled media sources
|
||||
inactiveCallMediaSourcesChanged = (inactiveCallMediaSources: CallMediaSources) => {
|
||||
const mic = inactiveCallMediaSources.mic
|
||||
const camera = inactiveCallMediaSources.camera
|
||||
const screenVideo = inactiveCallMediaSources.screenVideo
|
||||
enableMicIcon(mic)
|
||||
enableCameraIcon(camera)
|
||||
enableScreenIcon(screenVideo)
|
||||
|
||||
const className = camera ? CallMediaType.Video : CallMediaType.Audio
|
||||
document.getElementById("info-block")!.className = className
|
||||
// document.getElementById("media-sources")!.innerText = inactiveCallMediaSourcesStatus(inactiveCallMediaSources)
|
||||
}
|
||||
|
||||
function enableMicIcon(enabled: boolean) {
|
||||
document.getElementById("toggle-mic")!.innerHTML = enabled
|
||||
? '<img src="/desktop/images/ic_mic.svg" />'
|
||||
: '<img src="/desktop/images/ic_mic_off.svg" />'
|
||||
}
|
||||
|
||||
function enableCameraIcon(enabled: boolean) {
|
||||
document.getElementById("toggle-camera")!.innerHTML = enabled
|
||||
? '<img src="/desktop/images/ic_videocam_filled.svg" />'
|
||||
: '<img src="/desktop/images/ic_videocam_off.svg" />'
|
||||
}
|
||||
|
||||
function enableScreenIcon(enabled: boolean) {
|
||||
document.getElementById("toggle-screen")!.innerHTML = enabled
|
||||
? '<img src="/desktop/images/ic_stop_screen_share.svg" />'
|
||||
: '<img src="/desktop/images/ic_screen_share.svg" />'
|
||||
}
|
||||
|
||||
function enableSpeakerIcon(enabled: boolean, muted: boolean) {
|
||||
document.getElementById("toggle-speaker")!!.innerHTML = muted
|
||||
? '<img src="/desktop/images/ic_volume_off.svg" />'
|
||||
: enabled
|
||||
? '<img src="/desktop/images/ic_volume_up.svg" />'
|
||||
: '<img src="/desktop/images/ic_volume_down.svg" />'
|
||||
document.getElementById("toggle-speaker")!!.style.opacity = muted ? "0.7" : "1"
|
||||
}
|
||||
|
||||
function mediaSourcesStatus(call: Call): string {
|
||||
let status = "local"
|
||||
if (call.localMediaSources.mic) status += " mic"
|
||||
if (call.localMediaSources.camera) status += " cam"
|
||||
if (call.localMediaSources.screenAudio) status += " scrA"
|
||||
if (call.localMediaSources.screenVideo) status += " scrV"
|
||||
|
||||
status += " | peer"
|
||||
|
||||
if (call.peerMediaSources.mic) status += " mic"
|
||||
if (call.peerMediaSources.camera) status += " cam"
|
||||
if (call.peerMediaSources.screenAudio) status += " scrA"
|
||||
if (call.peerMediaSources.screenVideo) status += " scrV"
|
||||
return status
|
||||
}
|
||||
|
||||
function inactiveCallMediaSourcesStatus(inactiveCallMediaSources: CallMediaSources): string {
|
||||
let status = "local"
|
||||
const mic = inactiveCallMediaSources.mic
|
||||
const camera = inactiveCallMediaSources.camera
|
||||
const screenAudio = inactiveCallMediaSources.screenAudio
|
||||
const screenVideo = inactiveCallMediaSources.screenVideo
|
||||
if (mic) status += " mic"
|
||||
if (camera) status += " cam"
|
||||
if (screenAudio) status += " scrA"
|
||||
if (screenVideo) status += " scrV"
|
||||
return status
|
||||
}
|
||||
|
||||
function reactOnMessageFromServer(msg: WVApiMessage) {
|
||||
// screen is not allowed to be enabled before connection estabilished
|
||||
if (msg.command?.type == "capabilities" || msg.command?.type == "offer") {
|
||||
document.getElementById("toggle-screen")!!.style.opacity = "0.7"
|
||||
} else if (activeCall) {
|
||||
document.getElementById("toggle-screen")!!.style.opacity = "1"
|
||||
}
|
||||
switch (msg.command?.type) {
|
||||
case "capabilities":
|
||||
document.getElementById("info-block")!!.className = msg.command.media
|
||||
break
|
||||
case "offer":
|
||||
case "start":
|
||||
document.getElementById("toggle-audio")!!.style.display = "inline-block"
|
||||
document.getElementById("toggle-speaker")!!.style.display = "inline-block"
|
||||
if (msg.command.media == CallMediaType.Video) {
|
||||
document.getElementById("toggle-video")!!.style.display = "inline-block"
|
||||
document.getElementById("toggle-screen")!!.style.display = "inline-block"
|
||||
}
|
||||
document.getElementById("info-block")!!.className = msg.command.media
|
||||
document.getElementById("toggle-mic")!!.style.display = "inline-block"
|
||||
document.getElementById("toggle-speaker")!!.style.display = "inline-block"
|
||||
document.getElementById("toggle-camera")!!.style.display = "inline-block"
|
||||
document.getElementById("toggle-screen")!!.style.display = "inline-block"
|
||||
enableSpeakerIcon(true, true)
|
||||
break
|
||||
case "description":
|
||||
updateCallInfoView(msg.command.state, msg.command.description)
|
||||
if (activeCall?.connection.connectionState == "connected") {
|
||||
document.getElementById("progress")!.style.display = "none"
|
||||
if (document.getElementById("info-block")!!.className == CallMediaType.Audio) {
|
||||
document.getElementById("audio-call-icon")!.style.display = "block"
|
||||
}
|
||||
document.getElementById("audio-call-icon")!.style.display =
|
||||
document.getElementById("info-block")!!.className == CallMediaType.Audio ? "block" : "none"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function reactOnMessageToServer(msg: WVApiMessage) {
|
||||
if (!activeCall) return
|
||||
|
||||
switch (msg.resp?.type) {
|
||||
case "peerMedia":
|
||||
const className = localMedia(activeCall) == CallMediaType.Video || peerMedia(activeCall) == CallMediaType.Video ? "video" : "audio"
|
||||
document.getElementById("info-block")!!.className = className
|
||||
document.getElementById("audio-call-icon")!.style.display = className == CallMediaType.Audio ? "block" : "none"
|
||||
enableSpeakerIcon(
|
||||
activeCall.remoteStream.getAudioTracks().every((elem) => elem.enabled),
|
||||
!activeCall.peerMediaSources.mic
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function updateCallInfoView(state: string, description: string) {
|
||||
document.getElementById("state")!!.innerText = state
|
||||
document.getElementById("description")!!.innerText = description
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"https://github.com/simplex-chat/simplexmq.git"."309ef3766cc6b69e0c3aa0c140faab25383b732a" = "1ch03kizvsq3m5jwravyil529mc0lcfwj43czb1nhykbg8yb3cjv";
|
||||
"https://github.com/simplex-chat/simplexmq.git"."da79d544cf990fb0638ae5434407d6a30724fb87" = "11rxpcg2g781rbr5d20fw6zpv81q06w3c9bh6qh5cpnza230yvl3";
|
||||
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
|
||||
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
|
||||
@@ -7,4 +7,6 @@
|
||||
"https://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
|
||||
"https://github.com/simplex-chat/android-support.git"."9aa09f148089d6752ce563b14c2df1895718d806" = "0pbf2pf13v2kjzi397nr13f1h3jv0imvsq8rpiyy2qyx5vd50pqn";
|
||||
"https://github.com/simplex-chat/zip.git"."bd421c6b19cc4c465cd7af1f6f26169fb8ee1ebc" = "1csqfjhvc8wb5h4kxxndmb6iw7b4ib9ff2n81hrizsmnf45a6gg0";
|
||||
"https://github.com/yesodweb/wai.git"."ec5e017d896a78e787a5acea62b37a4e677dec2e" = "1ckcpmpjfy9jiqrb52q20lj7ln4hmq9v2jk6kpkf3m68c1m9c2bx";
|
||||
"https://github.com/simplex-chat/wai.git"."2f6e5aa5f05ba9140ac99e195ee647b4f7d926b0" = "199g4rjdf1zp1fcw8nqdsyr1h36hmg424qqx03071jk7j00z7ay4";
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 6.1.0.3
|
||||
version: 6.1.0.5
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -147,6 +147,7 @@ library
|
||||
Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays
|
||||
Simplex.Chat.Migrations.M20240528_quota_err_counter
|
||||
Simplex.Chat.Migrations.M20240827_calls_uuid
|
||||
Simplex.Chat.Migrations.M20240920_user_order
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.File
|
||||
Simplex.Chat.Mobile.Shared
|
||||
|
||||
@@ -593,8 +593,7 @@ processChatCommand' vr = \case
|
||||
user_ <- chatReadVar currentUser
|
||||
user' <- privateGetUser userId'
|
||||
validateUserPassword_ user_ user' viewPwd_
|
||||
withFastStore' (`setActiveUser` userId')
|
||||
let user'' = user' {activeUser = True}
|
||||
user'' <- withFastStore' (`setActiveUser` user')
|
||||
chatWriteVar currentUser $ Just user''
|
||||
pure $ CRActiveUser user''
|
||||
SetActiveUser uName viewPwd_ -> do
|
||||
@@ -4093,6 +4092,7 @@ processAgentMessageNoConn = \case
|
||||
UP srv conns -> serverEvent srv conns NSConnected CRContactsSubscribed
|
||||
SUSPENDED -> toView CRChatSuspended
|
||||
DEL_USER agentUserId -> toView $ CRAgentUserDeleted agentUserId
|
||||
ERRS cErrs -> errsEvent cErrs
|
||||
where
|
||||
hostEvent :: ChatResponse -> CM ()
|
||||
hostEvent = whenM (asks $ hostEvents . config) . toView
|
||||
@@ -4105,6 +4105,16 @@ processAgentMessageNoConn = \case
|
||||
notifyCLI = do
|
||||
cs <- withStore' (`getConnectionsContacts` conns)
|
||||
toView $ event srv cs
|
||||
errsEvent :: [(ConnId, AgentErrorType)] -> CM ()
|
||||
errsEvent cErrs = do
|
||||
vr <- chatVersionRange
|
||||
errs <- lift $ rights <$> withStoreBatch' (\db -> map (getChatErr vr db) cErrs)
|
||||
toView $ CRChatErrors Nothing errs
|
||||
where
|
||||
getChatErr :: VersionRangeChat -> DB.Connection -> (ConnId, AgentErrorType) -> IO ChatError
|
||||
getChatErr vr db (connId, err) =
|
||||
let acId = AgentConnId connId
|
||||
in ChatErrorAgent err <$> (getUserByAConnId db acId $>>= \user -> eitherToMaybe <$> runExceptT (getConnectionEntity db vr user acId))
|
||||
|
||||
processAgentMsgSndFile :: ACorrId -> SndFileId -> AEvent 'AESndFile -> CM ()
|
||||
processAgentMsgSndFile _corrId aFileId msg = do
|
||||
@@ -4917,7 +4927,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
XFile fInv -> processGroupFileInvitation' gInfo m' fInv msg brokerTs
|
||||
XFileCancel sharedMsgId -> xFileCancelGroup gInfo m' sharedMsgId
|
||||
XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInvGroup gInfo m' sharedMsgId fileConnReq_ fName
|
||||
XInfo p -> xInfoMember gInfo m' p
|
||||
XInfo p -> xInfoMember gInfo m' p brokerTs
|
||||
XGrpLinkMem p -> xGrpLinkMem gInfo m' conn' p
|
||||
XGrpMemNew memInfo -> xGrpMemNew gInfo m' memInfo msg brokerTs
|
||||
XGrpMemIntro memInfo memRestrictions_ -> xGrpMemIntro gInfo m' memInfo memRestrictions_
|
||||
@@ -6048,22 +6058,22 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
Profile {displayName = n, fullName = fn, image = i, contactLink = cl} = p
|
||||
Profile {displayName = n', fullName = fn', image = i', contactLink = cl'} = p'
|
||||
|
||||
xInfoMember :: GroupInfo -> GroupMember -> Profile -> CM ()
|
||||
xInfoMember gInfo m p' = void $ processMemberProfileUpdate gInfo m p' True
|
||||
xInfoMember :: GroupInfo -> GroupMember -> Profile -> UTCTime -> CM ()
|
||||
xInfoMember gInfo m p' brokerTs = void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs)
|
||||
|
||||
xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM ()
|
||||
xGrpLinkMem gInfo@GroupInfo {membership} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do
|
||||
xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId
|
||||
if viaGroupLink && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived
|
||||
then do
|
||||
m' <- processMemberProfileUpdate gInfo m p' False
|
||||
m' <- processMemberProfileUpdate gInfo m p' False Nothing
|
||||
withStore' $ \db -> setXGrpLinkMemReceived db groupMemberId True
|
||||
let connectedIncognito = memberIncognito membership
|
||||
probeMatchingMemberContact m' connectedIncognito
|
||||
else messageError "x.grp.link.mem error: invalid group link host profile update"
|
||||
|
||||
processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> CM GroupMember
|
||||
processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems
|
||||
processMemberProfileUpdate :: GroupInfo -> GroupMember -> Profile -> Bool -> Maybe UTCTime -> CM GroupMember
|
||||
processMemberProfileUpdate gInfo m@GroupMember {memberProfile = p, memberContactId} p' createItems itemTs_
|
||||
| redactedMemberProfile (fromLocalProfile p) /= redactedMemberProfile p' =
|
||||
case memberContactId of
|
||||
Nothing -> do
|
||||
@@ -6093,7 +6103,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
createProfileUpdatedItem m' =
|
||||
when createItems $ do
|
||||
let ciContent = CIRcvGroupEvent $ RGEMemberProfileUpdated (fromLocalProfile p) p'
|
||||
createInternalChatItem user (CDGroupRcv gInfo m') ciContent Nothing
|
||||
createInternalChatItem user (CDGroupRcv gInfo m') ciContent itemTs_
|
||||
|
||||
createFeatureEnabledItems :: Contact -> CM ()
|
||||
createFeatureEnabledItems ct@Contact {mergedPreferences} =
|
||||
@@ -6690,7 +6700,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
XMsgDel sharedMsgId memId -> groupMessageDelete gInfo author sharedMsgId memId rcvMsg msgTs
|
||||
XMsgReact sharedMsgId (Just memId) reaction add -> groupMsgReaction gInfo author sharedMsgId memId reaction add rcvMsg msgTs
|
||||
XFileCancel sharedMsgId -> xFileCancelGroup gInfo author sharedMsgId
|
||||
XInfo p -> xInfoMember gInfo author p
|
||||
XInfo p -> xInfoMember gInfo author p msgTs
|
||||
XGrpMemNew memInfo -> xGrpMemNew gInfo author memInfo rcvMsg msgTs
|
||||
XGrpMemRole memId memRole -> xGrpMemRole gInfo author memId memRole rcvMsg msgTs
|
||||
XGrpMemDel memId -> xGrpMemDel gInfo author memId rcvMsg msgTs
|
||||
|
||||
@@ -74,9 +74,7 @@ getSelectActiveUser st = do
|
||||
selectUser :: [User] -> IO (Maybe User)
|
||||
selectUser = \case
|
||||
[] -> pure Nothing
|
||||
[user@User {userId}] -> do
|
||||
withTransaction st (`setActiveUser` userId)
|
||||
pure $ Just user
|
||||
[user] -> Just <$> withTransaction st (`setActiveUser` user)
|
||||
users -> do
|
||||
putStrLn "Select user profile:"
|
||||
forM_ (zip [1 :: Int ..] users) $ \(n, user) -> putStrLn $ show n <> ": " <> userStr user
|
||||
@@ -88,10 +86,9 @@ getSelectActiveUser st = do
|
||||
Nothing -> putStrLn "not a number" >> loop
|
||||
Just n
|
||||
| n <= 0 || n > length users -> putStrLn "invalid user number" >> loop
|
||||
| otherwise -> do
|
||||
let user@User {userId} = users !! (n - 1)
|
||||
withTransaction st (`setActiveUser` userId)
|
||||
pure $ Just user
|
||||
| otherwise ->
|
||||
let user = users !! (n - 1)
|
||||
in Just <$> withTransaction st (`setActiveUser` user)
|
||||
|
||||
createActiveUser :: ChatController -> IO User
|
||||
createActiveUser cc = do
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Migrations.M20240920_user_order where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20240920_user_order :: Query
|
||||
m20240920_user_order =
|
||||
[sql|
|
||||
ALTER TABLE users ADD COLUMN active_order INTEGER NOT NULL DEFAULT 0;
|
||||
|]
|
||||
|
||||
down_m20240920_user_order :: Query
|
||||
down_m20240920_user_order =
|
||||
[sql|
|
||||
ALTER TABLE users DROP COLUMN active_order;
|
||||
|]
|
||||
@@ -35,7 +35,8 @@ CREATE TABLE users(
|
||||
send_rcpts_contacts INTEGER NOT NULL DEFAULT 0,
|
||||
send_rcpts_small_groups INTEGER NOT NULL DEFAULT 0,
|
||||
user_member_profile_updated_at TEXT,
|
||||
ui_themes TEXT, -- 1 for active user
|
||||
ui_themes TEXT,
|
||||
active_order INTEGER NOT NULL DEFAULT 0, -- 1 for active user
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE RESTRICT
|
||||
|
||||
@@ -54,6 +54,7 @@ import Simplex.Chat.Util (encryptFile, liftIOEither)
|
||||
import Simplex.FileTransfer.Description (FileDigest (..))
|
||||
import Simplex.Messaging.Agent
|
||||
import Simplex.Messaging.Agent.Protocol (AgentErrorType (RCP))
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..))
|
||||
import qualified Simplex.Messaging.Crypto.File as CF
|
||||
import Simplex.Messaging.Encoding.String (StrEncoding (..))
|
||||
@@ -72,11 +73,11 @@ import UnliftIO.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExis
|
||||
|
||||
-- when acting as host
|
||||
minRemoteCtrlVersion :: AppVersion
|
||||
minRemoteCtrlVersion = AppVersion [6, 1, 0, 2]
|
||||
minRemoteCtrlVersion = AppVersion [6, 1, 0, 4]
|
||||
|
||||
-- when acting as controller
|
||||
minRemoteHostVersion :: AppVersion
|
||||
minRemoteHostVersion = AppVersion [6, 1, 0, 2]
|
||||
minRemoteHostVersion = AppVersion [6, 1, 0, 4]
|
||||
|
||||
currentAppVersion :: AppVersion
|
||||
currentAppVersion = AppVersion SC.version
|
||||
@@ -497,31 +498,32 @@ handleRemoteCommand :: (ByteString -> CM' ChatResponse) -> RemoteCrypto -> TBQue
|
||||
handleRemoteCommand execChatCommand encryption remoteOutputQ HTTP2Request {request, reqBody, sendResponse} = do
|
||||
logDebug "handleRemoteCommand"
|
||||
liftIO (tryRemoteError' parseRequest) >>= \case
|
||||
Right (getNext, rc) -> do
|
||||
Right (rfKN, getNext, rc) -> do
|
||||
chatReadVar' currentUser >>= \case
|
||||
Nothing -> replyError $ ChatError CENoActiveUser
|
||||
Just user -> processCommand user getNext rc `catchChatError'` replyError
|
||||
Just user -> processCommand user rfKN getNext rc `catchChatError'` replyError
|
||||
Left e -> reply $ RRProtocolError e
|
||||
where
|
||||
parseRequest :: ExceptT RemoteProtocolError IO (GetChunk, RemoteCommand)
|
||||
parseRequest :: ExceptT RemoteProtocolError IO (C.SbKeyNonce, GetChunk, RemoteCommand)
|
||||
parseRequest = do
|
||||
(header, getNext) <- parseDecryptHTTP2Body encryption request reqBody
|
||||
(getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecode header)
|
||||
(rfKN, header, getNext) <- parseDecryptHTTP2Body encryption request reqBody
|
||||
(rfKN,getNext,) <$> liftEitherWith RPEInvalidJSON (J.eitherDecode header)
|
||||
replyError = reply . RRChatResponse . CRChatCmdError Nothing
|
||||
processCommand :: User -> GetChunk -> RemoteCommand -> CM ()
|
||||
processCommand user getNext = \case
|
||||
processCommand :: User -> C.SbKeyNonce -> GetChunk -> RemoteCommand -> CM ()
|
||||
processCommand user rfKN getNext = \case
|
||||
RCSend {command} -> lift $ handleSend execChatCommand command >>= reply
|
||||
RCRecv {wait = time} -> lift $ liftIO (handleRecv time remoteOutputQ) >>= reply
|
||||
RCStoreFile {fileName, fileSize, fileDigest} -> lift $ handleStoreFile encryption fileName fileSize fileDigest getNext >>= reply
|
||||
RCGetFile {file} -> handleGetFile encryption user file replyWith
|
||||
RCStoreFile {fileName, fileSize, fileDigest} -> lift $ handleStoreFile rfKN fileName fileSize fileDigest getNext >>= reply
|
||||
RCGetFile {file} -> handleGetFile user file replyWith
|
||||
reply :: RemoteResponse -> CM' ()
|
||||
reply = (`replyWith` \_ -> pure ())
|
||||
reply = (`replyWith` \_ _ -> pure ())
|
||||
replyWith :: Respond
|
||||
replyWith rr attach =
|
||||
liftIO (tryRemoteError' . encryptEncodeHTTP2Body encryption $ J.encode rr) >>= \case
|
||||
replyWith rr attach = do
|
||||
(corrId, cmdKN, sfKN) <- atomically $ getRemoteSndKeys encryption
|
||||
liftIO (tryRemoteError' . encryptEncodeHTTP2Body corrId cmdKN encryption $ J.encode rr) >>= \case
|
||||
Right resp -> liftIO . sendResponse . responseStreaming N.status200 [] $ \send flush -> do
|
||||
send resp
|
||||
attach send
|
||||
attach sfKN send
|
||||
flush
|
||||
Left e -> toView' . CRChatError Nothing . ChatErrorRemoteCtrl $ RCEProtocolError e
|
||||
|
||||
@@ -532,7 +534,7 @@ type GetChunk = Int -> IO ByteString
|
||||
|
||||
type SendChunk = Builder -> IO ()
|
||||
|
||||
type Respond = RemoteResponse -> (SendChunk -> IO ()) -> CM' ()
|
||||
type Respond = RemoteResponse -> (C.SbKeyNonce -> SendChunk -> IO ()) -> CM' ()
|
||||
|
||||
liftRC :: ExceptT RemoteProtocolError IO a -> CM a
|
||||
liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError)
|
||||
@@ -559,8 +561,8 @@ handleRecv time events = do
|
||||
|
||||
-- TODO this command could remember stored files and return IDs to allow removing files that are not needed.
|
||||
-- Also, there should be some process removing unused files uploaded to remote host (possibly, all unused files).
|
||||
handleStoreFile :: RemoteCrypto -> FilePath -> Word32 -> FileDigest -> GetChunk -> CM' RemoteResponse
|
||||
handleStoreFile encryption fileName fileSize fileDigest getChunk =
|
||||
handleStoreFile :: C.SbKeyNonce -> FilePath -> Word32 -> FileDigest -> GetChunk -> CM' RemoteResponse
|
||||
handleStoreFile rfKN fileName fileSize fileDigest getChunk =
|
||||
either RRProtocolError RRFileStored <$> (chatReadVar' filesFolder >>= storeFile)
|
||||
where
|
||||
storeFile :: Maybe FilePath -> CM' (Either RemoteProtocolError FilePath)
|
||||
@@ -570,11 +572,11 @@ handleStoreFile encryption fileName fileSize fileDigest getChunk =
|
||||
storeFileTo :: FilePath -> CM' (Either RemoteProtocolError FilePath)
|
||||
storeFileTo dir = liftIO . tryRemoteError' $ do
|
||||
filePath <- liftIO $ dir `uniqueCombine` fileName
|
||||
receiveEncryptedFile encryption getChunk fileSize fileDigest filePath
|
||||
receiveEncryptedFile rfKN getChunk fileSize fileDigest filePath
|
||||
pure filePath
|
||||
|
||||
handleGetFile :: RemoteCrypto -> User -> RemoteFile -> Respond -> CM ()
|
||||
handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do
|
||||
handleGetFile :: User -> RemoteFile -> Respond -> CM ()
|
||||
handleGetFile User {userId} RemoteFile {userId = commandUserId, fileId, sent, fileSource = cf'@CryptoFile {filePath}} reply = do
|
||||
logDebug $ "GetFile: " <> tshow filePath
|
||||
unless (userId == commandUserId) $ throwChatError $ CEDifferentActiveUser {commandUserId, activeUserId = userId}
|
||||
path <- maybe filePath (</> filePath) <$> chatReadVar filesFolder
|
||||
@@ -582,11 +584,12 @@ handleGetFile encryption User {userId} RemoteFile {userId = commandUserId, fileI
|
||||
cf <- getLocalCryptoFile db commandUserId fileId sent
|
||||
unless (cf == cf') $ throwError $ SEFileNotFound fileId
|
||||
liftRC (tryRemoteError $ getFileInfo path) >>= \case
|
||||
Left e -> lift $ reply (RRProtocolError e) $ \_ -> pure ()
|
||||
Left e -> lift $ reply (RRProtocolError e) $ \_ _ -> pure ()
|
||||
Right (fileSize, fileDigest) ->
|
||||
ExceptT . withFile path ReadMode $ \h -> runExceptT $ do
|
||||
encFile <- liftRC $ prepareEncryptedFile encryption (h, fileSize)
|
||||
lift $ reply RRFile {fileSize, fileDigest} $ sendEncryptedFile encFile
|
||||
lift . withFile path ReadMode $ \h -> do
|
||||
reply RRFile {fileSize, fileDigest} $ \sfKN send -> void . runExceptT $ do
|
||||
encFile <- prepareEncryptedFile sfKN (h, fileSize)
|
||||
liftIO $ sendEncryptedFile encFile send
|
||||
|
||||
listRemoteCtrls :: CM [RemoteCtrlInfo]
|
||||
listRemoteCtrls = do
|
||||
|
||||
@@ -43,6 +43,8 @@ import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
import Simplex.Messaging.Crypto.Lazy (LazyByteString)
|
||||
import Simplex.Messaging.Encoding
|
||||
import Simplex.Messaging.Parsers (dropPrefix, taggedObjectJSON, pattern SingleFieldJSONTag, pattern TaggedObjectJSONData, pattern TaggedObjectJSONTag)
|
||||
import qualified Simplex.Messaging.TMap as TM
|
||||
import Simplex.Messaging.Transport (TSbChainKeys)
|
||||
import Simplex.Messaging.Transport.Buffer (getBuffered)
|
||||
import Simplex.Messaging.Transport.HTTP2 (HTTP2Body (..), HTTP2BodyChunk, getBodyChunk)
|
||||
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client, HTTP2Response (..), closeHTTP2Client, sendRequestDirect)
|
||||
@@ -77,11 +79,9 @@ $(deriveJSON (taggedObjectJSON $ dropPrefix "RR") ''RemoteResponse)
|
||||
|
||||
mkRemoteHostClient :: HTTP2Client -> HostSessKeys -> SessionCode -> FilePath -> HostAppInfo -> CM RemoteHostClient
|
||||
mkRemoteHostClient httpClient sessionKeys sessionCode storePath HostAppInfo {encoding, deviceName, encryptFiles} = do
|
||||
drg <- asks random
|
||||
counter <- newTVarIO 1
|
||||
let HostSessKeys {hybridKey, idPrivKey, sessPrivKey} = sessionKeys
|
||||
let HostSessKeys {chainKeys, idPrivKey, sessPrivKey} = sessionKeys
|
||||
signatures = RSSign {idPrivKey, sessPrivKey}
|
||||
encryption = RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures}
|
||||
encryption <- liftIO $ mkRemoteCrypto sessionCode chainKeys signatures
|
||||
pure
|
||||
RemoteHostClient
|
||||
{ hostEncoding = encoding,
|
||||
@@ -93,11 +93,16 @@ mkRemoteHostClient httpClient sessionKeys sessionCode storePath HostAppInfo {enc
|
||||
}
|
||||
|
||||
mkCtrlRemoteCrypto :: CtrlSessKeys -> SessionCode -> CM RemoteCrypto
|
||||
mkCtrlRemoteCrypto CtrlSessKeys {hybridKey, idPubKey, sessPubKey} sessionCode = do
|
||||
drg <- asks random
|
||||
counter <- newTVarIO 1
|
||||
mkCtrlRemoteCrypto CtrlSessKeys {chainKeys, idPubKey, sessPubKey} sessionCode =
|
||||
let signatures = RSVerify {idPubKey, sessPubKey}
|
||||
pure RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures}
|
||||
in liftIO $ mkRemoteCrypto sessionCode chainKeys signatures
|
||||
|
||||
mkRemoteCrypto :: SessionCode -> TSbChainKeys -> RemoteSignatures -> IO RemoteCrypto
|
||||
mkRemoteCrypto sessionCode chainKeys signatures = do
|
||||
sndCounter <- newTVarIO 0
|
||||
rcvCounter <- newTVarIO 0
|
||||
skippedKeys <- liftIO TM.emptyIO
|
||||
pure RemoteCrypto {sessionCode, sndCounter, rcvCounter, chainKeys, skippedKeys, signatures}
|
||||
|
||||
closeRemoteHostClient :: RemoteHostClient -> IO ()
|
||||
closeRemoteHostClient RemoteHostClient {httpClient} = closeHTTP2Client httpClient
|
||||
@@ -125,26 +130,30 @@ remoteStoreFile c localPath fileName = do
|
||||
r -> badResponse r
|
||||
|
||||
remoteGetFile :: RemoteHostClient -> FilePath -> RemoteFile -> ExceptT RemoteProtocolError IO ()
|
||||
remoteGetFile c@RemoteHostClient {encryption} destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} =
|
||||
remoteGetFile c destDir rf@RemoteFile {fileSource = CryptoFile {filePath}} =
|
||||
sendRemoteCommand c Nothing RCGetFile {file = rf} >>= \case
|
||||
(getChunk, RRFile {fileSize, fileDigest}) -> do
|
||||
(rfKN, getChunk, RRFile {fileSize, fileDigest}) -> do
|
||||
-- TODO we could optimize by checking size and hash before receiving the file
|
||||
let localPath = destDir </> takeFileName filePath
|
||||
receiveEncryptedFile encryption getChunk fileSize fileDigest localPath
|
||||
(_, r) -> badResponse r
|
||||
receiveEncryptedFile rfKN getChunk fileSize fileDigest localPath
|
||||
(_, _, r) -> badResponse r
|
||||
|
||||
-- TODO validate there is no attachment in response
|
||||
sendRemoteCommand' :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO RemoteResponse
|
||||
sendRemoteCommand' c attachment_ rc = snd <$> sendRemoteCommand c attachment_ rc
|
||||
sendRemoteCommand' c attachment_ rc = do
|
||||
(_, _, r) <- sendRemoteCommand c attachment_ rc
|
||||
pure r
|
||||
|
||||
sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (Int -> IO ByteString, RemoteResponse)
|
||||
sendRemoteCommand :: RemoteHostClient -> Maybe (Handle, Word32) -> RemoteCommand -> ExceptT RemoteProtocolError IO (C.SbKeyNonce, Int -> IO ByteString, RemoteResponse)
|
||||
sendRemoteCommand RemoteHostClient {httpClient, hostEncoding, encryption} file_ cmd = do
|
||||
encFile_ <- mapM (prepareEncryptedFile encryption) file_
|
||||
req <- httpRequest encFile_ <$> encryptEncodeHTTP2Body encryption (J.encode cmd)
|
||||
(corrId, cmdKN, sfKN) <- atomically $ getRemoteSndKeys encryption
|
||||
encCmd <- encryptEncodeHTTP2Body corrId cmdKN encryption $ J.encode cmd
|
||||
encFile_ <- mapM (prepareEncryptedFile sfKN) file_
|
||||
let req = httpRequest encFile_ encCmd
|
||||
HTTP2Response {response, respBody} <- liftError' (RPEHTTP2 . tshow) $ sendRequestDirect httpClient req Nothing
|
||||
(header, getNext) <- parseDecryptHTTP2Body encryption response respBody
|
||||
(rfKN, header, getNext) <- parseDecryptHTTP2Body encryption response respBody
|
||||
rr <- liftEitherWith (RPEInvalidJSON . fromString) $ J.eitherDecode header >>= JT.parseEither J.parseJSON . convertJSON hostEncoding localEncoding
|
||||
pure (getNext, rr)
|
||||
pure (rfKN, getNext, rr)
|
||||
where
|
||||
httpRequest encFile_ cmdBld = H.requestStreaming N.methodPost "/" mempty $ \send flush -> do
|
||||
send cmdBld
|
||||
@@ -213,13 +222,11 @@ pattern OwsfTag = (SingleFieldJSONTag, J.Bool True)
|
||||
|
||||
-- See https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2023-10-25-remote-control.md for encoding
|
||||
|
||||
encryptEncodeHTTP2Body :: RemoteCrypto -> LazyByteString -> ExceptT RemoteProtocolError IO Builder
|
||||
encryptEncodeHTTP2Body RemoteCrypto {drg, counter, sessionCode, hybridKey, signatures} s = do
|
||||
corrId <- atomically $ stateTVar counter $ \c -> (c, c + 1)
|
||||
let pfx = smpEncode (sessionCode, corrId)
|
||||
(nonce, ct) <- liftError PRERemoteControl $ RC.rcEncryptBody drg hybridKey $ LB.fromStrict pfx <> s
|
||||
encryptEncodeHTTP2Body :: Word32 -> C.SbKeyNonce -> RemoteCrypto -> LazyByteString -> ExceptT RemoteProtocolError IO Builder
|
||||
encryptEncodeHTTP2Body corrId cmdKN RemoteCrypto {sessionCode, signatures} s = do
|
||||
ct <- liftError PRERemoteControl $ RC.rcEncryptBody cmdKN $ LB.fromStrict (smpEncode sessionCode) <> s
|
||||
let ctLen = encodeWord32 (fromIntegral $ LB.length ct)
|
||||
signed = LB.fromStrict (smpEncode nonce <> ctLen) <> ct
|
||||
signed = LB.fromStrict (encodeWord32 corrId <> ctLen) <> ct
|
||||
sigs <- bodySignatures signed
|
||||
pure $ lazyByteString signed <> sigs
|
||||
where
|
||||
@@ -235,24 +242,25 @@ encryptEncodeHTTP2Body RemoteCrypto {drg, counter, sessionCode, hybridKey, signa
|
||||
sign k = C.signatureBytes . C.sign' k . BA.convert . CH.hashFinalize
|
||||
|
||||
-- | Parse and decrypt HTTP2 request/response
|
||||
parseDecryptHTTP2Body :: HTTP2BodyChunk a => RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (LazyByteString, Int -> IO ByteString)
|
||||
parseDecryptHTTP2Body RemoteCrypto {hybridKey, sessionCode, signatures} hr HTTP2Body {bodyBuffer} = do
|
||||
(nonce, ct) <- getBody
|
||||
s <- liftError PRERemoteControl $ RC.rcDecryptBody hybridKey nonce ct
|
||||
(,getNext) <$> parseBody s
|
||||
parseDecryptHTTP2Body :: HTTP2BodyChunk a => RemoteCrypto -> a -> HTTP2Body -> ExceptT RemoteProtocolError IO (C.SbKeyNonce, LazyByteString, Int -> IO ByteString)
|
||||
parseDecryptHTTP2Body rc@RemoteCrypto {sessionCode, signatures} hr HTTP2Body {bodyBuffer} = do
|
||||
(corrId, ct) <- getBody
|
||||
(cmdKN, rfKN) <- ExceptT $ atomically $ getRemoteRcvKeys rc corrId
|
||||
s <- liftError PRERemoteControl $ RC.rcDecryptBody cmdKN ct
|
||||
s' <- parseBody s
|
||||
pure (rfKN, s', getNext)
|
||||
where
|
||||
getBody :: ExceptT RemoteProtocolError IO (C.CbNonce, LazyByteString)
|
||||
getBody :: ExceptT RemoteProtocolError IO (Word32, LazyByteString)
|
||||
getBody = do
|
||||
nonceStr <- liftIO $ getNext 24
|
||||
nonce <- liftEitherWith RPEInvalidBody $ smpDecode nonceStr
|
||||
corrIdStr <- liftIO $ getNext 4
|
||||
ctLenStr <- liftIO $ getNext 4
|
||||
let ctLen = decodeWord32 ctLenStr
|
||||
when (ctLen > fromIntegral (maxBound :: Int)) $ throwError RPEInvalidSize
|
||||
chunks <- liftIO $ getLazy $ fromIntegral ctLen
|
||||
let hc = CH.hashUpdates (CH.hashInit @SHA512) [nonceStr, ctLenStr]
|
||||
let hc = CH.hashUpdates (CH.hashInit @SHA512) [corrIdStr, ctLenStr]
|
||||
hc' = CH.hashUpdates hc chunks
|
||||
verifySignatures hc'
|
||||
pure (nonce, LB.fromChunks chunks)
|
||||
pure (decodeWord32 corrIdStr, LB.fromChunks chunks)
|
||||
getLazy :: Int -> IO [ByteString]
|
||||
getLazy 0 = pure []
|
||||
getLazy n = do
|
||||
@@ -279,9 +287,8 @@ parseDecryptHTTP2Body RemoteCrypto {hybridKey, sessionCode, signatures} hr HTTP2
|
||||
parseBody s = case LB.uncons s of
|
||||
Nothing -> throwError $ RPEInvalidBody "empty body"
|
||||
Just (scLen, rest) -> do
|
||||
(sessCode', rest') <- takeBytes (fromIntegral scLen) rest
|
||||
(sessCode', s') <- takeBytes (fromIntegral scLen) rest
|
||||
unless (sessCode' == sessionCode) $ throwError PRESessionCode
|
||||
(_corrId, s') <- takeBytes 8 rest'
|
||||
pure s'
|
||||
where
|
||||
takeBytes n s' = do
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{-# LANGUAGE NamedFieldPuns #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
|
||||
module Simplex.Chat.Remote.Transport where
|
||||
|
||||
@@ -20,27 +21,25 @@ import Simplex.RemoteControl.Types (RCErrorType (..))
|
||||
import UnliftIO
|
||||
import UnliftIO.Directory (getFileSize)
|
||||
|
||||
type EncryptedFile = ((Handle, Word32), C.CbNonce, LC.SbState)
|
||||
type EncryptedFile = ((Handle, Word32), LC.SbState)
|
||||
|
||||
prepareEncryptedFile :: RemoteCrypto -> (Handle, Word32) -> ExceptT RemoteProtocolError IO EncryptedFile
|
||||
prepareEncryptedFile RemoteCrypto {drg, hybridKey} f = do
|
||||
nonce <- atomically $ C.randomCbNonce drg
|
||||
sbState <- liftEitherWith (const $ PRERemoteControl RCEEncrypt) $ LC.kcbInit hybridKey nonce
|
||||
pure (f, nonce, sbState)
|
||||
prepareEncryptedFile :: C.SbKeyNonce -> (Handle, Word32) -> ExceptT RemoteProtocolError IO EncryptedFile
|
||||
prepareEncryptedFile (sk, nonce) f = do
|
||||
sbState <- liftEitherWith (const $ PRERemoteControl RCEEncrypt) $ LC.sbInit sk nonce
|
||||
pure (f, sbState)
|
||||
|
||||
sendEncryptedFile :: EncryptedFile -> (Builder -> IO ()) -> IO ()
|
||||
sendEncryptedFile ((h, sz), nonce, sbState) send = do
|
||||
send $ byteString $ smpEncode ('\x01', nonce, sz + fromIntegral C.authTagSize)
|
||||
sendEncryptedFile ((h, sz), sbState) send = do
|
||||
send $ byteString $ smpEncode ('\x01', sz + fromIntegral C.authTagSize)
|
||||
sendEncFile h send sbState sz
|
||||
|
||||
receiveEncryptedFile :: RemoteCrypto -> (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO ()
|
||||
receiveEncryptedFile RemoteCrypto {hybridKey} getChunk fileSize fileDigest toPath = do
|
||||
receiveEncryptedFile :: C.SbKeyNonce -> (Int -> IO ByteString) -> Word32 -> FileDigest -> FilePath -> ExceptT RemoteProtocolError IO ()
|
||||
receiveEncryptedFile (sk, nonce) getChunk fileSize fileDigest toPath = do
|
||||
c <- liftIO $ getChunk 1
|
||||
unless (c == "\x01") $ throwError RPENoFile
|
||||
nonce <- liftError' RPEInvalidBody $ smpDecode <$> getChunk 24
|
||||
size <- liftError' RPEInvalidBody $ smpDecode <$> getChunk 4
|
||||
unless (size == fileSize + fromIntegral C.authTagSize) $ throwError RPEFileSize
|
||||
sbState <- liftEitherWith (const $ PRERemoteControl RCEDecrypt) $ LC.kcbInit hybridKey nonce
|
||||
sbState <- liftEitherWith (const $ PRERemoteControl RCEDecrypt) $ LC.sbInit sk nonce
|
||||
liftError' fErr $ withFile toPath WriteMode $ \h -> receiveSbFile getChunk h sbState fileSize
|
||||
digest <- liftIO $ LC.sha512Hash <$> LB.readFile toPath
|
||||
unless (FileDigest digest == fileDigest) $ throwError RPEFileDigest
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{-# LANGUAGE BangPatterns #-}
|
||||
{-# LANGUAGE CPP #-}
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE DeriveAnyClass #-}
|
||||
@@ -11,22 +12,22 @@
|
||||
module Simplex.Chat.Remote.Types where
|
||||
|
||||
import Control.Concurrent.Async (Async)
|
||||
import Control.Concurrent.STM (TVar)
|
||||
import Control.Concurrent.STM
|
||||
import Control.Exception (Exception)
|
||||
import Crypto.Random (ChaChaDRG)
|
||||
import Control.Monad (when)
|
||||
import qualified Data.Aeson.TH as J
|
||||
import Data.ByteString (ByteString)
|
||||
import Data.Int (Int64)
|
||||
import Data.Text (Text)
|
||||
import Data.Word (Word16)
|
||||
import Data.Word (Word16, Word32)
|
||||
import Simplex.Chat.Remote.AppVersion
|
||||
import Simplex.Chat.Types (verificationCode)
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile)
|
||||
import Simplex.Messaging.Crypto.SNTRUP761 (KEMHybridSecret)
|
||||
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, enumJSON, sumTypeJSON)
|
||||
import Simplex.Messaging.Transport (TLS (..))
|
||||
import Simplex.Messaging.Transport (TLS (..), TSbChainKeys (..))
|
||||
import Simplex.Messaging.Transport.HTTP2.Client (HTTP2Client)
|
||||
import qualified Simplex.Messaging.TMap as TM
|
||||
import Simplex.RemoteControl.Client
|
||||
import Simplex.RemoteControl.Types
|
||||
|
||||
@@ -40,13 +41,45 @@ data RemoteHostClient = RemoteHostClient
|
||||
}
|
||||
|
||||
data RemoteCrypto = RemoteCrypto
|
||||
{ drg :: TVar ChaChaDRG,
|
||||
counter :: TVar Int64,
|
||||
sessionCode :: ByteString,
|
||||
hybridKey :: KEMHybridSecret,
|
||||
{ sessionCode :: ByteString,
|
||||
sndCounter :: TVar Word32,
|
||||
rcvCounter :: TVar Word32,
|
||||
chainKeys :: TSbChainKeys,
|
||||
skippedKeys :: TM.TMap Word32 (C.SbKeyNonce, C.SbKeyNonce),
|
||||
signatures :: RemoteSignatures
|
||||
}
|
||||
|
||||
getRemoteSndKeys :: RemoteCrypto -> STM (Word32, C.SbKeyNonce, C.SbKeyNonce)
|
||||
getRemoteSndKeys RemoteCrypto {sndCounter, chainKeys = TSbChainKeys {sndKey}} = do
|
||||
corrId <- stateTVar sndCounter $ \c -> let !c' = c + 1 in (c', c')
|
||||
cmdKN <- stateTVar sndKey C.sbcHkdf
|
||||
fileKN <- stateTVar sndKey C.sbcHkdf
|
||||
pure (corrId, cmdKN, fileKN)
|
||||
|
||||
getRemoteRcvKeys :: RemoteCrypto -> Word32 -> STM (Either RemoteProtocolError (C.SbKeyNonce, C.SbKeyNonce))
|
||||
getRemoteRcvKeys RemoteCrypto {rcvCounter, chainKeys = TSbChainKeys {rcvKey}, skippedKeys} !corrId =
|
||||
readTVar rcvCounter >>= getRcvKeys
|
||||
where
|
||||
getRcvKeys prevCorrId
|
||||
| prevCorrId > corrId =
|
||||
let err = PREEarlierId $ prevCorrId - corrId
|
||||
in maybe (Left err) Right <$> TM.lookupDelete corrId skippedKeys
|
||||
| prevCorrId == corrId =
|
||||
pure $ Left PREDuplicateId
|
||||
| prevCorrId + maxSkip < corrId =
|
||||
pure $ Left $ RPEManySkippedIds (corrId - prevCorrId)
|
||||
| otherwise = do -- prevCorrId < corrId
|
||||
writeTVar rcvCounter corrId
|
||||
skipKeys (prevCorrId + 1)
|
||||
Right <$> getKeys
|
||||
maxSkip = 256
|
||||
getKeys = (,) <$> stateTVar rcvKey C.sbcHkdf <*> stateTVar rcvKey C.sbcHkdf
|
||||
skipKeys !cId =
|
||||
when (cId < corrId) $ do
|
||||
keys <- getKeys
|
||||
TM.insert cId keys skippedKeys
|
||||
skipKeys (cId + 1)
|
||||
|
||||
data RemoteSignatures
|
||||
= RSSign
|
||||
{ idPrivKey :: C.PrivateKeyEd25519,
|
||||
@@ -110,6 +143,9 @@ data RemoteProtocolError
|
||||
| RPENoFile
|
||||
| RPEFileSize
|
||||
| RPEFileDigest
|
||||
| RPEManySkippedIds Word32
|
||||
| PREEarlierId Word32
|
||||
| PREDuplicateId
|
||||
| -- | Wrong response received for the command sent
|
||||
RPEUnexpectedResponse {response :: Text}
|
||||
| -- | A file already exists in the destination position
|
||||
|
||||
@@ -1580,7 +1580,7 @@ getAllChatItems db vr user@User {userId} pagination search_ = do
|
||||
CPLast count -> liftIO $ getAllChatItemsLast_ count
|
||||
CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem_ afterId
|
||||
CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem_ beforeId
|
||||
mapM (uncurry (getAChatItem db vr user) >=> liftIO . getACIReactions db) itemRefs
|
||||
mapM (uncurry (getAChatItem db vr user)) itemRefs
|
||||
where
|
||||
search = fromMaybe "" search_
|
||||
getAChatItem_ itemId = do
|
||||
@@ -2279,20 +2279,22 @@ getChatRefViaItemId db User {userId} itemId = do
|
||||
(_, _) -> Left $ SEBadChatItem itemId Nothing
|
||||
|
||||
getAChatItem :: DB.Connection -> VersionRangeChat -> User -> ChatRef -> ChatItemId -> ExceptT StoreError IO AChatItem
|
||||
getAChatItem db vr user chatRef itemId = case chatRef of
|
||||
ChatRef CTDirect contactId -> do
|
||||
ct <- getContact db vr user contactId
|
||||
(CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId
|
||||
pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci
|
||||
ChatRef CTGroup groupId -> do
|
||||
gInfo <- getGroupInfo db vr user groupId
|
||||
(CChatItem msgDir ci) <- getGroupChatItem db user groupId itemId
|
||||
pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci
|
||||
ChatRef CTLocal folderId -> do
|
||||
nf <- getNoteFolder db user folderId
|
||||
CChatItem msgDir ci <- getLocalChatItem db user folderId itemId
|
||||
pure $ AChatItem SCTLocal msgDir (LocalChat nf) ci
|
||||
_ -> throwError $ SEChatItemNotFound itemId
|
||||
getAChatItem db vr user chatRef itemId = do
|
||||
aci <- case chatRef of
|
||||
ChatRef CTDirect contactId -> do
|
||||
ct <- getContact db vr user contactId
|
||||
(CChatItem msgDir ci) <- getDirectChatItem db user contactId itemId
|
||||
pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci
|
||||
ChatRef CTGroup groupId -> do
|
||||
gInfo <- getGroupInfo db vr user groupId
|
||||
(CChatItem msgDir ci) <- getGroupChatItem db user groupId itemId
|
||||
pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci
|
||||
ChatRef CTLocal folderId -> do
|
||||
nf <- getNoteFolder db user folderId
|
||||
CChatItem msgDir ci <- getLocalChatItem db user folderId itemId
|
||||
pure $ AChatItem SCTLocal msgDir (LocalChat nf) ci
|
||||
_ -> throwError $ SEChatItemNotFound itemId
|
||||
liftIO $ getACIReactions db aci
|
||||
|
||||
getAChatItemBySharedMsgId :: ChatTypeQuotable c => DB.Connection -> User -> ChatDirection c 'MDRcv -> SharedMsgId -> ExceptT StoreError IO AChatItem
|
||||
getAChatItemBySharedMsgId db user cd sharedMsgId = case cd of
|
||||
|
||||
@@ -111,6 +111,7 @@ import Simplex.Chat.Migrations.M20240510_chat_items_via_proxy
|
||||
import Simplex.Chat.Migrations.M20240515_rcv_files_user_approved_relays
|
||||
import Simplex.Chat.Migrations.M20240528_quota_err_counter
|
||||
import Simplex.Chat.Migrations.M20240827_calls_uuid
|
||||
import Simplex.Chat.Migrations.M20240920_user_order
|
||||
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -221,7 +222,8 @@ schemaMigrations =
|
||||
("20240510_chat_items_via_proxy", m20240510_chat_items_via_proxy, Just down_m20240510_chat_items_via_proxy),
|
||||
("20240515_rcv_files_user_approved_relays", m20240515_rcv_files_user_approved_relays, Just down_m20240515_rcv_files_user_approved_relays),
|
||||
("20240528_quota_err_counter", m20240528_quota_err_counter, Just down_m20240528_quota_err_counter),
|
||||
("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid)
|
||||
("20240827_calls_uuid", m20240827_calls_uuid, Just down_m20240827_calls_uuid),
|
||||
("20240920_user_order", m20240920_user_order, Just down_m20240920_user_order)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -19,7 +19,6 @@ module Simplex.Chat.Store.Profiles
|
||||
getUsersInfo,
|
||||
getUsers,
|
||||
setActiveUser,
|
||||
getSetActiveUser,
|
||||
getUser,
|
||||
getUserIdByName,
|
||||
getUserByAConnId,
|
||||
@@ -106,10 +105,11 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image,
|
||||
let showNtfs = True
|
||||
sendRcptsContacts = True
|
||||
sendRcptsSmallGroups = True
|
||||
order <- getNextActiveOrder db
|
||||
DB.execute
|
||||
db
|
||||
"INSERT INTO users (agent_user_id, local_display_name, active_user, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,0,?,?,?,?,?)"
|
||||
(auId, displayName, activeUser, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, currentTs, currentTs)
|
||||
"INSERT INTO users (agent_user_id, local_display_name, active_user, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, created_at, updated_at) VALUES (?,?,?,?,0,?,?,?,?,?)"
|
||||
(auId, displayName, activeUser, order, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, currentTs, currentTs)
|
||||
userId <- insertedRowId db
|
||||
DB.execute
|
||||
db
|
||||
@@ -126,7 +126,7 @@ createUserRecordAt db (AgentUserId auId) Profile {displayName, fullName, image,
|
||||
(profileId, displayName, userId, True, currentTs, currentTs, currentTs)
|
||||
contactId <- insertedRowId db
|
||||
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
|
||||
pure $ toUser $ (userId, auId, contactId, profileId, activeUser, displayName, fullName, image, Nothing, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing)
|
||||
pure $ toUser $ (userId, auId, contactId, profileId, activeUser, order, displayName, fullName, image, Nothing, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, Nothing, Nothing, Nothing, Nothing)
|
||||
|
||||
getUsersInfo :: DB.Connection -> IO [UserInfo]
|
||||
getUsersInfo db = getUsers db >>= mapM getUserInfo
|
||||
@@ -161,15 +161,19 @@ getUsers :: DB.Connection -> IO [User]
|
||||
getUsers db =
|
||||
map toUser <$> DB.query_ db userQuery
|
||||
|
||||
setActiveUser :: DB.Connection -> UserId -> IO ()
|
||||
setActiveUser db userId = do
|
||||
setActiveUser :: DB.Connection -> User -> IO User
|
||||
setActiveUser db user@User {userId} = do
|
||||
DB.execute_ db "UPDATE users SET active_user = 0"
|
||||
DB.execute db "UPDATE users SET active_user = 1 WHERE user_id = ?" (Only userId)
|
||||
activeOrder <- getNextActiveOrder db
|
||||
DB.execute db "UPDATE users SET active_user = 1, active_order = ? WHERE user_id = ?" (activeOrder, userId)
|
||||
pure user {activeUser = True, activeOrder}
|
||||
|
||||
getSetActiveUser :: DB.Connection -> UserId -> ExceptT StoreError IO User
|
||||
getSetActiveUser db userId = do
|
||||
liftIO $ setActiveUser db userId
|
||||
getUser db userId
|
||||
getNextActiveOrder :: DB.Connection -> IO Int64
|
||||
getNextActiveOrder db = do
|
||||
order <- fromMaybe 0 . join <$> maybeFirstRow fromOnly (DB.query_ db "SELECT max(active_order) FROM users")
|
||||
if order == maxBound
|
||||
then 0 <$ DB.execute db "UPDATE users SET active_order = active_order - ?" (Only (maxBound :: Int64))
|
||||
else pure $ order + 1
|
||||
|
||||
getUser :: DB.Connection -> UserId -> ExceptT StoreError IO User
|
||||
getUser db userId =
|
||||
|
||||
@@ -422,16 +422,16 @@ toContactRequest ((contactRequestId, localDisplayName, agentInvitationId, userCo
|
||||
userQuery :: Query
|
||||
userQuery =
|
||||
[sql|
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.local_display_name, ucp.full_name, ucp.image, ucp.contact_link, ucp.preferences,
|
||||
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.image, ucp.contact_link, ucp.preferences,
|
||||
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.ui_themes
|
||||
FROM users u
|
||||
JOIN contacts uct ON uct.contact_id = u.contact_id
|
||||
JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id
|
||||
|]
|
||||
|
||||
toUser :: (UserId, UserId, ContactId, ProfileId, Bool, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User
|
||||
toUser ((userId, auId, userContactId, profileId, activeUser, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) =
|
||||
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt, uiThemes}
|
||||
toUser :: (UserId, UserId, ContactId, ProfileId, Bool, Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, Maybe Preferences) :. (Bool, Bool, Bool, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, Maybe UIThemeEntityOverrides) -> User
|
||||
toUser ((userId, auId, userContactId, profileId, activeUser, activeOrder, displayName, fullName, image, contactLink, userPreferences) :. (showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, uiThemes)) =
|
||||
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, viewPwdHash, userMemberProfileUpdatedAt, uiThemes}
|
||||
where
|
||||
profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences = userPreferences, localAlias = ""}
|
||||
fullPreferences = mergePreferences Nothing userPreferences
|
||||
|
||||
@@ -113,6 +113,7 @@ data User = User
|
||||
profile :: LocalProfile,
|
||||
fullPreferences :: FullPreferences,
|
||||
activeUser :: Bool,
|
||||
activeOrder :: Int64,
|
||||
viewPwdHash :: Maybe UserPwdHash,
|
||||
showNtfs :: Bool,
|
||||
sendRcptsContacts :: Bool,
|
||||
|
||||
@@ -52,7 +52,7 @@ import Simplex.Messaging.Protocol (srvHostnamesSMPClientVersion)
|
||||
import Simplex.Messaging.Server (runSMPServerBlocking)
|
||||
import Simplex.Messaging.Server.Env.STM
|
||||
import Simplex.Messaging.Transport
|
||||
import Simplex.Messaging.Transport.Server (TransportServerConfig (..), defaultTransportServerConfig)
|
||||
import Simplex.Messaging.Transport.Server (ServerCredentials (..), TransportServerConfig (..), defaultTransportServerConfig)
|
||||
import Simplex.Messaging.Version
|
||||
import Simplex.Messaging.Version.Internal
|
||||
import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive)
|
||||
@@ -420,7 +420,7 @@ concurrentlyN_ = mapConcurrently_ id
|
||||
smpServerCfg :: ServerConfig
|
||||
smpServerCfg =
|
||||
ServerConfig
|
||||
{ transports = [(serverPort, transport @TLS)],
|
||||
{ transports = [(serverPort, transport @TLS, False)],
|
||||
tbqSize = 1,
|
||||
-- serverTbqSize = 1,
|
||||
msgQueueQuota = 16,
|
||||
@@ -428,23 +428,30 @@ smpServerCfg =
|
||||
msgIdBytes = 6,
|
||||
storeLogFile = Nothing,
|
||||
storeMsgsFile = Nothing,
|
||||
storeNtfsFile = Nothing,
|
||||
allowNewQueues = True,
|
||||
-- server password is disabled as otherwise v1 tests fail
|
||||
newQueueBasicAuth = Nothing, -- Just "server_password",
|
||||
controlPortUserAuth = Nothing,
|
||||
controlPortAdminAuth = Nothing,
|
||||
messageExpiration = Just defaultMessageExpiration,
|
||||
notificationExpiration = defaultNtfExpiration,
|
||||
inactiveClientExpiration = Just defaultInactiveClientExpiration,
|
||||
caCertificateFile = "tests/fixtures/tls/ca.crt",
|
||||
privateKeyFile = "tests/fixtures/tls/server.key",
|
||||
certificateFile = "tests/fixtures/tls/server.crt",
|
||||
smpCredentials =
|
||||
ServerCredentials
|
||||
{ caCertificateFile = Just "tests/fixtures/tls/ca.crt",
|
||||
privateKeyFile = "tests/fixtures/tls/server.key",
|
||||
certificateFile = "tests/fixtures/tls/server.crt"
|
||||
},
|
||||
httpCredentials = Nothing,
|
||||
logStatsInterval = Nothing,
|
||||
logStatsStartTime = 0,
|
||||
serverStatsLogFile = "tests/smp-server-stats.daily.log",
|
||||
serverStatsBackupFile = Nothing,
|
||||
pendingENDInterval = 500000,
|
||||
ntfDeliveryInterval = 200000,
|
||||
smpServerVRange = supportedServerSMPRelayVRange,
|
||||
transportConfig = defaultTransportServerConfig {alpn = Just supportedSMPHandshakes},
|
||||
transportConfig = defaultTransportServerConfig,
|
||||
smpHandshakeTimeout = 1000000,
|
||||
controlPort = Nothing,
|
||||
smpAgentCfg = defaultSMPClientAgentConfig,
|
||||
@@ -457,7 +464,7 @@ withSmpServer :: IO () -> IO ()
|
||||
withSmpServer = withSmpServer' smpServerCfg
|
||||
|
||||
withSmpServer' :: ServerConfig -> IO () -> IO ()
|
||||
withSmpServer' cfg = serverBracket (`runSMPServerBlocking` cfg)
|
||||
withSmpServer' cfg = serverBracket (\started -> runSMPServerBlocking started cfg Nothing)
|
||||
|
||||
xftpTestPort :: ServiceName
|
||||
xftpTestPort = "7002"
|
||||
@@ -481,16 +488,19 @@ xftpServerConfig =
|
||||
fileExpiration = Just defaultFileExpiration,
|
||||
fileTimeout = 10000000,
|
||||
inactiveClientExpiration = Just defaultInactiveClientExpiration,
|
||||
caCertificateFile = "tests/fixtures/tls/ca.crt",
|
||||
privateKeyFile = "tests/fixtures/tls/server.key",
|
||||
certificateFile = "tests/fixtures/tls/server.crt",
|
||||
xftpCredentials =
|
||||
ServerCredentials
|
||||
{ caCertificateFile = Just "tests/fixtures/tls/ca.crt",
|
||||
privateKeyFile = "tests/fixtures/tls/server.key",
|
||||
certificateFile = "tests/fixtures/tls/server.crt"
|
||||
},
|
||||
xftpServerVRange = supportedFileServerVRange,
|
||||
logStatsInterval = Nothing,
|
||||
logStatsStartTime = 0,
|
||||
serverStatsLogFile = "tests/tmp/xftp-server-stats.daily.log",
|
||||
serverStatsBackupFile = Nothing,
|
||||
controlPort = Nothing,
|
||||
transportConfig = defaultTransportServerConfig {alpn = Just supportedXFTPhandshakes},
|
||||
transportConfig = defaultTransportServerConfig,
|
||||
responseDelay = 0
|
||||
}
|
||||
|
||||
@@ -502,7 +512,7 @@ withXFTPServer' cfg =
|
||||
serverBracket
|
||||
( \started -> do
|
||||
createDirectoryIfMissing False xftpServerFiles
|
||||
runXFTPServerBlocking started cfg
|
||||
runXFTPServerBlocking started cfg Nothing
|
||||
)
|
||||
|
||||
serverBracket :: (TMVar Bool -> IO ()) -> IO () -> IO ()
|
||||
|
||||
@@ -6419,7 +6419,7 @@ testGroupMemberInactive tmp = do
|
||||
where
|
||||
serverCfg' =
|
||||
smpServerCfg
|
||||
{ transports = [("7003", transport @TLS)],
|
||||
{ transports = [("7003", transport @TLS, False)],
|
||||
msgQueueQuota = 2
|
||||
}
|
||||
fastRetryInterval = defaultReconnectInterval {initialInterval = 50_000} -- same as in agent tests
|
||||
|
||||
@@ -1689,7 +1689,7 @@ testChangePCCUserDiffSrv tmp = do
|
||||
where
|
||||
serverCfg' =
|
||||
smpServerCfg
|
||||
{ transports = [("7003", transport @TLS), ("7002", transport @TLS)],
|
||||
{ transports = [("7003", transport @TLS, False), ("7002", transport @TLS, False)],
|
||||
msgQueueQuota = 2
|
||||
}
|
||||
|
||||
|
||||
@@ -94,10 +94,10 @@ activeUserExists =
|
||||
#endif
|
||||
|
||||
activeUserExistsSwift :: LB.ByteString
|
||||
activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}"
|
||||
activeUserExistsSwift = "{\"resp\":{\"_owsf\":true,\"chatCmdError\":{\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"_owsf\":true,\"error\":{\"errorType\":{\"_owsf\":true,\"userExists\":{\"contactName\":\"alice\"}}}}}}}"
|
||||
|
||||
activeUserExistsTagged :: LB.ByteString
|
||||
activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}"
|
||||
activeUserExistsTagged = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true},\"chatError\":{\"type\":\"error\",\"errorType\":{\"type\":\"userExists\",\"contactName\":\"alice\"}}}}"
|
||||
|
||||
activeUser :: LB.ByteString
|
||||
activeUser =
|
||||
@@ -108,10 +108,10 @@ activeUser =
|
||||
#endif
|
||||
|
||||
activeUserSwift :: LB.ByteString
|
||||
activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}"
|
||||
activeUserSwift = "{\"resp\":{\"_owsf\":true,\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}}"
|
||||
|
||||
activeUserTagged :: LB.ByteString
|
||||
activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}"
|
||||
activeUserTagged = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}}}"
|
||||
|
||||
chatStarted :: LB.ByteString
|
||||
chatStarted =
|
||||
@@ -184,7 +184,7 @@ pendingSubSummaryTagged :: LB.ByteString
|
||||
pendingSubSummaryTagged = "{\"resp\":{\"type\":\"pendingSubSummary\",\"user\":" <> userJSON <> ",\"pendingSubscriptions\":[]}}"
|
||||
|
||||
userJSON :: LB.ByteString
|
||||
userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}"
|
||||
userJSON = "{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"activeOrder\":1,\"showNtfs\":true,\"sendRcptsContacts\":true,\"sendRcptsSmallGroups\":true}"
|
||||
|
||||
parsedMarkdown :: LB.ByteString
|
||||
parsedMarkdown =
|
||||
|
||||