Compare commits

..

22 Commits

Author SHA1 Message Date
Avently 6e875d4632 android: disable compression and extraction of libs 2024-09-13 18:12:56 +07:00
Evgeny 4cfda91124 core: fix ForwardConfirmation encoding (#4872) 2024-09-13 09:35:11 +01:00
Evgeny Poberezkin 7aec147cec core: update simplexmq 6.0.4.0 2024-09-12 22:19:17 +01:00
Evgeny f6f2044675 core: plan forwarding chat items, api types (#4865)
* core: plan forwarding chat items, api types

* remove empty content, refactor get items

* another refactor

* plan

* test

* more tests

* text
2024-09-12 15:21:29 +01:00
Stanislav Dmitrenko 5f0b5c5a9f desktop: fix vlc dependency (2) (#4869) 2024-09-12 14:51:57 +01:00
Evgeny Poberezkin dfdb4af646 Revert "core: bulk forward missing files error handling (#4860)"
This reverts commit 46d774a822.
2024-09-12 08:52:09 +01:00
Narasimha-sc 2ab5f14119 docs: remove outdated latest version number from downloads (#4854) 2024-09-11 21:31:05 +01:00
Diogo 46d774a822 core: bulk forward missing files error handling (#4860)
* add types

* wip dump

* collect errors

* Update src/Simplex/Chat/View.hs

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* test with not received files

* remove ciFileLoaded

* undo refactoring

* test for skipping missing file with text

* add test for empty message

* remove fdescribes

* copy or cleanup files after collecting errors and forward reqs

* don't forward w/t content

* translate CIFSRcvAborted into FFENotAccepted

* refactor

* refactor

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-09-11 21:30:09 +01:00
Diogo acf2f1fbbe android, desktop: new user picker (#4796)
* user picker as modal

* android dirty layout

* color mode switcher

* close picker on desktop opening modals

* cleanup

* remote hosts working

* icon buttons

* profile picker modal for external shares

* remove stroke

* color changes

* add unread badge to users row

* chat database settings section

* safe remove of you settings section

* picker should now open for single user

* remove create profile from settings

* paddings

* handle big names

* fonts and align

* simple animations and shadow

* address messaging

* active is grey

* padding

* hide non active devices from pills

* picker positioning

* pills order

* change view all profiles icon

* bigger space between profiles

* hosts ordering and fixes

* device pill in app bar

* simplex address -> public

* better switch of opacity bg

* create public address

* font match

* add icon for dark mode

* padding

* profile name as header

* h2 is too big

* icon colors

* icons

* settings as modal

* center settings

* fix use from desktop

* remove logs

* bar colors

* remove drawer unused code

* animate shading

* fade in fade out

* add system mode toast

* shading colors

* stop pushing shade up

* same button as ios for opening all profiles

* simplify nav bar color set

* broken transition change

* color mix

* gradient and horizontal scroll

* separate title

* align avatars to top

* picker should always remain open

* use chevron icon to see all profiles

* improvements on status and nav color set

* best case bars switching working

* change bar and shading on theme change

* remove unused var

* reset navbar colors on navigate

* updated icon color

* protect android calls

* desktop menu matching size of right side modals

* remove shading from desktop

* close user picker on settings click in desktop

* bigger profile image smaller gap to name

* fix spacer for row scroll on android

* smaller profile name

* remove unused code

* small refactor

* unused

* move desktop/mobile connection

* close drawer on swipe down 30%

* progress dump on new android design

* paddings in scroller

* gradient

* android paddings

* split inactive user picker between platforms

* move your chat profiles inside android specific

* always show your chat profiles in desktop

* fix profile creation in desktop

* remove unused var

* update android space between badges

* initial desktop design

* center android icons with avatar

* centered avatars

* unread badge

* extra space in the end of user list for android

* aligned paddings on desktop

* desktop paddings

* paddings

* remove you

* unread badge same style as chatlist

* use bedtime moon for dark mode

* chevron same size as sun/moon

* chevron and gradient

* paddings

* split android and desktop scaffold for picker

* move bars logic to android

* remove android check

* more android checks

* initial version of swipable modal

* muted as grey

* unused

* close drawer on 3/4

* better close control

* make all animations match

* move shadow with offset

* always close pciker on selection

* animated float doing nothing

* sync animation

* animation using single float

* fixed warnings

* better state update

* fix scrim color

* better handling of picker closure on desktop

* landscape mode

* intentation

* rename UserPickerScaffold

* hide shadow when picker not open

* reset inactive user scroll position on pick

* unused class

* left panel after new menu can be without padding

* small changes

* make ActiveProfilePicker reusable to reduce code duplication

* make picker scrollable

* refactor

* refactor and fix instant reload of profiles

* refactor

* icon sizes

* returned back ability to scroll to the picker on Android

* setting system theme on desktop's right click

* box

* refactor

* picker pill

* fix desktop shadow

* small change

* hiding keyboard when opening picker

* state specifying

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2024-09-11 15:51:28 +01:00
Evgeny Poberezkin 1a853d4eea Merge branch 'stable' 2024-09-11 13:06:11 +01:00
spaced4ndy 3ea8279451 multiplatform: fix delete messages alert (#4862) 2024-09-11 13:04:59 +01:00
spaced4ndy 87c55dbbf7 multiplatform: fix delete messages alert (#4862) 2024-09-11 15:08:16 +04:00
Evgeny fb4475027d ios: new user picker (#4821)
* ios: new user picker (#4770)

* current user picker progress

* one hand picker

* thin bullet icon

* more user picker buttons

* button clickable areas

* divider padding

* extra space after sun

* send current user option to address view

* add unread count badge

* with anim for apperance close

* edit current profile from picker

* remove you section from settings

* remove help and support

* simplify

* move settings and sun to same row

* remove redundant vstack

* long press on sun/moon switches to system setting

* remove back button from migrate device

* smooth profile transitions

* close user picker on list profiles

* fix dismiss on migrate from device

* fix dismiss when deleting last visible user while having hidden users

* picker visibility toggle tweaks

* remove strange square from profile switcher click

* dirty way to save auto accept settings on dismiss

* Revert "dirty way to save auto accept settings on dismiss"

This reverts commit e7b19ee8aa.

* consistent animation on user picker toggle

* change space between profiles

* remove result

* ignore result

* unread badge

* move to sheet

* half sheet on one hand ui

* fix dismiss on device migration

* fix desktop connect

* sun to meet other action icons

* fill bullet list button

* fix tap in settings to take full width

* icon sizings and paddings

* open settings in same sheet

* apply same trick as other buttons for ligth toggle

* layout

* open profiles sheet large when +3 users

* layout

* layout

* paddings

* paddings

* remove show progress

* always small user picker

* fixed height

* open all actions as sheets

* type, color

* simpler and more effective way of avoid moving around on user select

* dismiss user profiles sheet on user change

* connect desktop back button remove

* remove back buttons from user address view

* remove porgress

* header inside list

* alert on auto accept unsaved changes

* Cancel -> Discard

* revert

* fix connect to desktop

* remove extra space

* fix share inside multi sheet

* user picker and options as separate sheet

* revert showShareSheet

* fix current profile and all profiles selection

* change alert

* update

* cleanup user address

* remove func

* alert on unsaved changes in chat prefs

* fix layout

* cleanup

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: fix switching profiles (#4822)

* ios: different user picker layout (#4826)

* ios: different user picker layout

* remove section

* layout, color

* color

* remove activeUser

* fix gradient

* recursive sheets

* gradient padding

* share sheet

* layout

* dismiss sheets

---------

Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>

* ios: use the same way to share from all sheets (#4829)

* ios: close user picker before opening other sheets

* Revert "share sheet"

This reverts commit 0064155825.

* dismiss/show via callback

* Revert "ios: close user picker before opening other sheets"

This reverts commit 19110398f8.

* ios: show alerts from sheets (#4839)

* padding

* remove gradient

* cleanup

* simplify settings

* padding

---------

Co-authored-by: Diogo <diogofncunha@gmail.com>
Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-09-10 09:31:53 +01:00
spaced4ndy 388609563d core: update simplexmq (ntf encoding) (#4853) 2024-09-09 18:22:14 +04:00
Evgeny 0cb568d206 fix incorrect error of migration to device (#4852)
* fix incorrect error of migration to device

* alert to finish migration, ios fix

* simplexmq

* catching exception and stopping chat

* text

---------

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2024-09-09 15:01:18 +01:00
Arturs Krumins 8f6e9741e7 ios: add floating date separator (#4801)
* ios: add floating date separator

* floating date separator

* revert formatTimestampText

* send tuple, reduce lookups

* background date visibility

* whitespace

* streamline

* visible date

* move pipeline to ReverseList

* space

* remove ViewUpdate

* cleanup

* refactor

* combine unread items model updates

* split publisher

* remove readItemPublisher

* revert markChatItemRead_ change

* use single item api

* comment test buttons

* style

* update top floating button instantly

* cleanup

* cleanup

* minor

* remove task

* prevent concurrent updates

* fix mark chat read

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-09-09 14:58:22 +01:00
Evgeny Poberezkin e00001b571 Merge branch 'stable' 2024-09-08 23:01:54 +01:00
Evgeny 351cfcbcbc core: allow deleting user when user record in agent database was deleted (#4851) 2024-09-08 23:01:31 +01:00
Evgeny 74b837bf9a core: allow deleting user when user record in agent database was deleted (#4851) 2024-09-08 20:02:38 +01:00
Henrique Albuquerque 691cd489ea blog: fix grammar error (#4846) 2024-09-08 12:56:15 +01:00
Evgeny 5ed701402b core: optimize marking chat items as read, batch API (#4843)
* core: optimize marking chat items as read

* tests, ui types

* ios: fix api

* refactor
2024-09-07 19:40:10 +01:00
Evgeny 1839dab17b ios: move caching images to background thread, dont use main thread scheduler for marking items read (#4840) 2024-09-06 22:09:55 +01:00
70 changed files with 2008 additions and 1145 deletions
+1
View File
@@ -61,6 +61,7 @@ website/package/generated*
# Ignore build tool output, e.g. code coverage
website/.nyc_output/
website/coverage/
result
# Ignore API documentation
website/api-docs/
-5
View File
@@ -15,16 +15,11 @@ class AppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
logger.debug("AppDelegate: didFinishLaunchingWithOptions")
application.registerForRemoteNotifications()
NotificationCenter.default.addObserver(self, selector: #selector(pasteboardChanged), name: UIPasteboard.changedNotification, object: nil)
removePasscodesIfReinstalled()
prepareForLaunch()
return true
}
@objc func pasteboardChanged() {
ChatModel.shared.pasteboardHasStrings = UIPasteboard.general.hasStrings
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
let token = deviceToken.map { String(format: "%02hhx", $0) }.joined()
logger.debug("AppDelegate: didRegisterForRemoteNotificationsWithDeviceToken \(token)")
+18 -14
View File
@@ -61,7 +61,7 @@ class ItemsModel: ObservableObject {
init() {
publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
@@ -191,7 +191,6 @@ final class ChatModel: ObservableObject {
@Published var stopPreviousRecPlay: URL? = nil // coordinates currently playing source
@Published var draft: ComposeState?
@Published var draftChatId: String?
@Published var pasteboardHasStrings: Bool = UIPasteboard.general.hasStrings
@Published var networkInfo = UserNetworkInfo(networkType: .other, online: true)
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
@@ -563,6 +562,7 @@ final class ChatModel: ObservableObject {
// update preview
_updateChat(cInfo.id) { chat in
self.decreaseUnreadCounter(user: self.currentUser!, by: chat.chatStats.unreadCount)
self.updateFloatingButtons(unreadCount: 0)
chat.chatStats = ChatStats()
}
// update current chat
@@ -579,6 +579,12 @@ final class ChatModel: ObservableObject {
}
}
private func updateFloatingButtons(unreadCount: Int) {
let fbm = ChatView.FloatingButtonModel.shared
fbm.totalUnread = unreadCount
fbm.objectWillChange.send()
}
func markChatItemsRead(_ cInfo: ChatInfo, aboveItem: ChatItem? = nil) {
if let cItem = aboveItem {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
@@ -597,6 +603,7 @@ final class ChatModel: ObservableObject {
if markedCount > 0 {
chat.chatStats.unreadCount -= markedCount
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
self.updateFloatingButtons(unreadCount: chat.chatStats.unreadCount)
}
}
}
@@ -626,19 +633,15 @@ final class ChatModel: ObservableObject {
}
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
if chatId == cInfo.id,
let itemIndex = getChatItemIndex(cItem),
im.reversedChatItems[itemIndex].isRcvNew {
await MainActor.run {
withTransaction(Transaction()) {
// update current chat
markChatItemRead_(itemIndex)
// update preview
unreadCollector.changeUnreadCounter(cInfo.id, by: -1)
func markChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) {
if self.chatId == cInfo.id {
for itemId in itemIds {
if let i = im.reversedChatItems.firstIndex(where: { $0.id == itemId }) {
markChatItemRead_(i)
}
}
}
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count)
}
private let unreadCollector = UnreadCollector()
@@ -664,9 +667,10 @@ final class ChatModel: ObservableObject {
}
func changeUnreadCounter(_ chatId: ChatId, by count: Int) {
DispatchQueue.main.async {
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
if chatId == ChatModel.shared.chatId {
ChatView.FloatingButtonModel.shared.totalUnread += count
}
self.unreadCounts[chatId] = (self.unreadCounts[chatId] ?? 0) + count
subject.send()
}
}
+19 -3
View File
@@ -1000,6 +1000,10 @@ func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async thr
try await sendCommandOkResp(.apiChatRead(type: type, id: id, itemRange: itemRange))
}
func apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64]) async throws {
try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, itemIds: itemIds))
}
func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws {
try await sendCommandOkResp(.apiChatUnread(type: type, id: id, unreadChat: unreadChat))
}
@@ -1287,11 +1291,23 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async {
func apiMarkChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
logger.debug("apiMarkChatItemRead: \(cItem.id)")
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
await ChatModel.shared.markChatItemRead(cInfo, cItem)
DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, [cItem.id])
}
} catch {
logger.error("apiMarkChatItemRead apiChatRead error: \(responseError(error))")
logger.error("apiChatRead error: \(responseError(error))")
}
}
func apiMarkChatItemsRead(_ cInfo: ChatInfo, _ itemIds: [ChatItem.ID]) async {
do {
try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, itemIds: itemIds)
DispatchQueue.main.async {
ChatModel.shared.markChatItemsRead(cInfo, itemIds)
}
} catch {
logger.error("apiChatItemsRead error: \(responseError(error))")
}
}
@@ -38,6 +38,7 @@ struct IncomingCallView: View {
}
HStack {
ProfilePreview(profileOf: invitation.contact, color: .white)
.padding(.vertical, 6)
Spacer()
callButton("Reject", "phone.down.fill", .red) {
+134 -143
View File
@@ -23,7 +23,6 @@ struct ChatView: View {
@Environment(\.scenePhase) var scenePhase
@State @ObservedObject var chat: Chat
@StateObject private var scrollModel = ReverseListScrollModel()
@StateObject private var floatingButtonModel: FloatingButtonModel = .shared
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@@ -76,13 +75,7 @@ struct ChatView: View {
VStack(spacing: 0) {
ZStack(alignment: .bottomTrailing) {
chatItemsList()
// TODO: Extract into a separate view, to reduce the scope of `FloatingButtonModel` updates
floatingButtons(
unreadBelow: floatingButtonModel.unreadBelow,
isNearBottom: floatingButtonModel.isNearBottom,
date: floatingButtonModel.date,
isDateVisible: floatingButtonModel.isDateVisible
)
FloatingButtons(theme: theme, scrollModel: scrollModel, chat: chat)
}
connectingText()
if selectedChatItems == nil {
@@ -346,6 +339,7 @@ struct ChatView: View {
await markChatUnread(chat, unreadChat: false)
}
}
ChatView.FloatingButtonModel.shared.totalUnread = chat.chatStats.unreadCount
}
private func searchToolbar() -> some View {
@@ -432,7 +426,7 @@ struct ChatView: View {
.onChange(of: im.itemAdded) { added in
if added {
im.itemAdded = false
if floatingButtonModel.isReallyNearBottom {
if FloatingButtonModel.shared.isReallyNearBottom {
scrollModel.scrollToBottom()
}
}
@@ -460,65 +454,57 @@ struct ChatView: View {
@Published var isNearBottom: Bool = true
@Published var date: Date?
@Published var isDateVisible: Bool = false
var isReallyNearBottom: Bool { scrollOffset.value > 0 && scrollOffset.value < 500 }
let listState = PassthroughSubject<ListState, Never>()
let scrollOffset = CurrentValueSubject<Double, Never>(0)
var totalUnread: Int = 0
var isReallyNearBottom: Bool = true
var hideDateWorkItem: DispatchWorkItem?
private var bag = Set<AnyCancellable>()
struct ListState: Equatable {
let topItemDate: Date?
let bottomItemId: ChatItem.ID?
}
private struct ViewUpdate: Equatable {
let unreadBelow: Int
let date: Date?
}
init() {
// Date
listState
.receive(on: DispatchQueue.global(qos: .background))
.map { listState -> ViewUpdate in
let im = ItemsModel.shared
return ViewUpdate(
unreadBelow: {
if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id }) {
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 }
}
} else { 0 }
}(),
date: listState.topItemDate.map { Calendar.current.startOfDay(for: $0) }
)
func updateOnListChange(_ listState: ListState) {
let im = ItemsModel.shared
let unreadBelow =
if let id = listState.bottomItemId,
let index = im.reversedChatItems.firstIndex(where: { $0.id == id })
{
im.reversedChatItems[..<index].reduce(into: 0) { unread, chatItem in
if chatItem.isRcvNew { unread += 1 }
}
} else {
0
}
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink {
self.unreadBelow = $0.unreadBelow
self.date = $0.date
let date: Date? =
if let topItemDate = listState.topItemDate {
Calendar.current.startOfDay(for: topItemDate)
} else {
nil
}
.store(in: &bag)
// Date visibility
listState
.removeDuplicates()
.map { _ in self.setDate(visibility: true) }
// Hide the date after 1 second of no scrolling
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink { self.setDate(visibility: false) }
.store(in: &bag)
// Is near bottom
scrollOffset
.map { $0 < 800 }
.removeDuplicates()
// Delay the state change until scroll to bottom animation is finished
.delay(for: 0.35, scheduler: DispatchQueue.main)
.assign(to: \.isNearBottom, on: self)
.store(in: &bag)
// set the counters and date indicator
DispatchQueue.main.async { [weak self] in
guard let it = self else { return }
it.setDate(visibility: true)
it.unreadBelow = unreadBelow
it.date = date
it.isReallyNearBottom = listState.scrollOffset > 0 && listState.scrollOffset < 500
}
// set floating button indication mode
let nearBottom = listState.scrollOffset < 800
if nearBottom != self.isNearBottom {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in
self?.isNearBottom = nearBottom
}
}
// hide Date indicator after 1 second of no scrolling
hideDateWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
guard let it = self else { return }
it.setDate(visibility: false)
it.hideDateWorkItem = nil
}
DispatchQueue.main.async { [weak self] in
self?.hideDateWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: workItem)
}
}
func resetDate() {
@@ -527,86 +513,86 @@ struct ChatView: View {
}
private func setDate(visibility isVisible: Bool) {
Task {
if isVisible {
if !isNearBottom,
!isDateVisible,
let date, !Calendar.current.isDateInToday(date) {
await MainActor.run {
isDateVisible = true
}
}
} else if isDateVisible {
await MainActor.run {
isDateVisible = false
}
if isVisible {
if !isNearBottom,
!isDateVisible,
let date, !Calendar.current.isDateInToday(date) {
withAnimation { self.isDateVisible = true }
}
} else if isDateVisible {
withAnimation { self.isDateVisible = false }
}
}
}
@ViewBuilder
private func floatingButtons(unreadBelow: Int, isNearBottom: Bool, date: Date?, isDateVisible: Bool) -> some View {
ZStack(alignment: .top) {
if let date {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
.clipShape(Capsule())
.opacity(isDateVisible ? 1 : 0)
}
VStack {
let unreadAbove = chat.chatStats.unreadCount - unreadBelow
if unreadAbove > 0 {
circleButton {
unreadCountText(unreadAbove)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToNextPage()
}
.contextMenu {
Button {
Task {
await markChatRead(chat)
private struct FloatingButtons: View {
let theme: AppTheme
let scrollModel: ReverseListScrollModel
let chat: Chat
@ObservedObject var model = FloatingButtonModel.shared
var body: some View {
ZStack(alignment: .top) {
if let date = model.date {
DateSeparator(date: date)
.padding(.vertical, 4).padding(.horizontal, 8)
.background(.thinMaterial)
.clipShape(Capsule())
.opacity(model.isDateVisible ? 1 : 0)
}
VStack {
let unreadAbove = model.totalUnread - model.unreadBelow
if unreadAbove > 0 {
circleButton {
unreadCountText(unreadAbove)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToNextPage()
}
.contextMenu {
Button {
Task {
await markChatRead(chat)
}
} label: {
Label("Mark read", systemImage: "checkmark")
}
} label: {
Label("Mark read", systemImage: "checkmark")
}
}
Spacer()
if model.unreadBelow > 0 {
circleButton {
unreadCountText(model.unreadBelow)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToBottom()
}
} else if !model.isNearBottom {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
}
.onTapGesture { scrollModel.scrollToBottom() }
}
}
Spacer()
if unreadBelow > 0 {
circleButton {
unreadCountText(unreadBelow)
.font(.callout)
.foregroundColor(theme.colors.primary)
}
.onTapGesture {
scrollModel.scrollToBottom()
}
} else if !isNearBottom {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
}
.onTapGesture { scrollModel.scrollToBottom() }
}
.padding()
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding()
.frame(maxWidth: .infinity, alignment: .trailing)
.onDisappear(perform: model.resetDate)
}
.onDisappear(perform: floatingButtonModel.resetDate)
}
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
ZStack {
Circle()
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
.frame(width: 44, height: 44)
content()
private func circleButton<Content: View>(_ content: @escaping () -> Content) -> some View {
ZStack {
Circle()
.foregroundColor(Color(uiColor: .tertiarySystemGroupedBackground))
.frame(width: 44, height: 44)
content()
}
}
}
@@ -782,6 +768,7 @@ struct ChatView: View {
@Binding var selectedChatItems: Set<Int64>?
@State private var allowMenu: Bool = true
@State private var markedRead = false
var revealed: Bool { chatItem == revealedChatItem }
@@ -848,12 +835,16 @@ struct ChatView: View {
}
}
.onAppear {
if markedRead {
return
} else {
markedRead = true
}
if let range {
if let items = unreadItems(range) {
let itemIds = unreadItemIds(range)
if !itemIds.isEmpty {
waitToMarkRead {
for ci in items {
await apiMarkChatItemRead(chat.chatInfo, ci)
}
await apiMarkChatItemsRead(chat.chatInfo, itemIds)
}
}
} else if chatItem.isRcvNew {
@@ -863,24 +854,24 @@ struct ChatView: View {
}
}
}
private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? {
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
let im = ItemsModel.shared
let items = range.compactMap { i in
return range.compactMap { i in
if i >= 0 && i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
return if ci.isRcvNew { ci } else { nil }
return if ci.isRcvNew { ci.id } else { nil }
} else {
return nil
}
}
return if items.isEmpty { nil } else { items }
}
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
Task {
_ = try? await Task.sleep(nanoseconds: 600_000000)
if m.chatId == chat.chatInfo.id {
Task(operation: op)
await op()
}
}
}
+30 -16
View File
@@ -107,9 +107,14 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
name: notificationName,
object: nil
)
updateFloatingButtons
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
.sink { self.updateVisibleItems() }
.throttle(for: 0.2, scheduler: DispatchQueue.global(qos: .background), latest: true)
.sink {
if let listState = DispatchQueue.main.sync(execute: { [weak self] in self?.getListState() }) {
ChatView.FloatingButtonModel.shared.updateOnListChange(listState)
}
}
.store(in: &bag)
}
@@ -203,24 +208,27 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
updateFloatingButtons.send()
}
private func updateVisibleItems() {
func getListState() -> ListState? {
if let visibleRows = tableView.indexPathsForVisibleRows,
visibleRows.last?.item ?? 0 < representer.items.count {
let fbm = ChatView.FloatingButtonModel.shared
fbm.scrollOffset.send(tableView.contentOffset.y + InvertedTableView.inset)
fbm.listState.send(.init(
topItemDate: visibleRows
.last { isVisible(indexPath: $0) }
.map { representer.items[$0.item] }?
.meta.itemTs,
bottomItemId: visibleRows
.first { isVisible(indexPath: $0) }
.map { representer.items[$0.item] }?
.id
))
let scrollOffset: Double = tableView.contentOffset.y + InvertedTableView.inset
let topItemDate: Date? =
if let lastVisible = visibleRows.last(where: { isVisible(indexPath: $0) }) {
representer.items[lastVisible.item].meta.itemTs
} else {
nil
}
let bottomItemId: ChatItem.ID? =
if let firstVisible = visibleRows.first(where: { isVisible(indexPath: $0) }) {
representer.items[firstVisible.item].id
} else {
nil
}
return (scrollOffset: scrollOffset, topItemDate: topItemDate, bottomItemId: bottomItemId)
}
return nil
}
private func isVisible(indexPath: IndexPath) -> Bool {
if let relativeFrame = tableView.superview?.convert(
tableView.rectForRow(at: indexPath),
@@ -272,6 +280,12 @@ struct ReverseList<Content: View>: UIViewControllerRepresentable {
}
}
typealias ListState = (
scrollOffset: Double,
topItemDate: Date?,
bottomItemId: ChatItem.ID?
)
/// Manages ``ReverseList`` scrolling
class ReverseListScrollModel: ObservableObject {
/// Represents Scroll State of ``ReverseList``
@@ -9,6 +9,17 @@
import SwiftUI
import SimpleXChat
enum UserPickerSheet: Identifiable {
case address
case chatPreferences
case chatProfiles
case currentProfile
case useFromDesktop
case settings
var id: Self { self }
}
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@@ -18,9 +29,9 @@ struct ChatListView: View {
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@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
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@@ -46,21 +57,44 @@ struct ChatListView: View {
),
destination: chatView
) { chatListView }
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
}
.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("Public address")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
}
case .chatProfiles:
NavigationView {
UserProfilesView()
}
case .currentProfile:
NavigationView {
UserProfile()
.navigationTitle("Your current profile")
.modifier(ThemedBackground())
}
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)
}
}
}
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
}
}
@@ -73,7 +107,7 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.onDisappear() { withAnimation { userPickerVisible = false } }
.onDisappear() { activeUserPickerSheet = nil }
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -164,7 +198,7 @@ struct ChatListView: View {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding(.trailing, 4)
.padding([.top, .trailing], 3)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
@@ -173,13 +207,7 @@ struct ChatListView: View {
}
}
.onTapGesture {
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
userPickerShown = true
}
}
@@ -269,7 +297,7 @@ struct ChatListView: View {
}
}
private func unreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
private func unreadBadge(size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(theme.colors.primary)
+188 -139
View File
@@ -8,179 +8,228 @@ import SimpleXChat
struct UserPicker: View {
@EnvironmentObject var m: ChatModel
@Environment(\.scenePhase) var scenePhase
@EnvironmentObject var theme: AppTheme
@Binding var showSettings: Bool
@Binding var showConnectDesktop: Bool
@Binding var userPickerVisible: Bool
@State var scrollViewContentSize: CGSize = .zero
@State var disableScrolling: Bool = true
private let menuButtonHeight: CGFloat = 68
@State var chatViewNameWidth: CGFloat = 0
@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 activeSheet: UserPickerSheet?
@State private var switchingProfile = false
var body: some View {
VStack {
Spacer().frame(height: 1)
VStack(spacing: 0) {
ScrollView {
ScrollViewReader { sp in
let users = m.users
.filter({ u in u.user.activeUser || !u.user.hidden })
.sorted { u, _ in u.user.activeUser }
VStack(spacing: 0) {
ForEach(users) { u in
userView(u)
Divider()
if u.user.activeUser { Divider() }
}
}
.overlay {
GeometryReader { geo -> Color in
DispatchQueue.main.async {
scrollViewContentSize = geo.size
let scenes = UIApplication.shared.connectedScenes
if let windowScene = scenes.first as? UIWindowScene {
let layoutFrame = windowScene.windows[0].safeAreaLayoutGuide.layoutFrame
disableScrolling = scrollViewContentSize.height + menuButtonHeight + 10 < layoutFrame.height
}
}
return Color.clear
}
}
.onChange(of: userPickerVisible) { visible in
if visible, let u = users.first {
sp.scrollTo(u.id)
if #available(iOS 16.0, *) {
let v = viewBody.presentationDetents([.height(420)])
if #available(iOS 16.4, *) {
v.scrollBounceBehavior(.basedOnSize)
} else {
v
}
} 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
} else {
v.padding(.vertical, 4)
}
}
}) {
activeSheet = .currentProfile
}
}
.simultaneousGesture(DragGesture(minimumDistance: disableScrolling ? 0 : 10000000))
.frame(maxHeight: scrollViewContentSize.height)
menuButton("Use from desktop", icon: "desktopcomputer") {
showConnectDesktop = true
withAnimation {
userPickerVisible.toggle()
openSheetOnTap(title: m.userAddress == nil ? "Create public address" : "Your public address", icon: "qrcode") {
activeSheet = .address
}
openSheetOnTap(title: "Chat preferences", icon: "switch.2") {
activeSheet = .chatPreferences
}
}
Divider()
menuButton("Settings", icon: "gearshape") {
showSettings = true
withAnimation {
userPickerVisible.toggle()
}
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)
}
}
}
}
.clipShape(RoundedRectangle(cornerRadius: 16))
.background(
Rectangle()
.fill(theme.colors.surface)
.cornerRadius(16)
.shadow(color: .black.opacity(0.12), radius: 24, x: 0, y: 0)
)
.onPreferenceChange(DetermineWidth.Key.self) { chatViewNameWidth = $0 }
.frame(maxWidth: chatViewNameWidth > 0 ? min(300, chatViewNameWidth + 130) : 300)
.padding(8)
.opacity(userPickerVisible ? 1.0 : 0.0)
.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 {
Task {
do {
let users = try await listUsersAsync()
await MainActor.run { m.users = users }
} catch {
logger.error("Error loading users \(responseError(error))")
}
}
}
}
}
private func userView(_ u: UserInfo) -> some View {
let user = u.user
return Button(action: {
if user.activeUser {
showSettings = true
withAnimation {
userPickerVisible.toggle()
}
} else {
// This check prevents the call of listUsers after the app is suspended, and the database is closed.
if case .active = scenePhase {
Task {
do {
try await changeActiveUserAsync_(user.userId, viewPwd: nil)
await MainActor.run { userPickerVisible = false }
let users = try await listUsersAsync()
await MainActor.run { m.users = users }
} catch {
await MainActor.run {
AlertManager.shared.showAlertMsg(
title: "Error switching profile!",
message: "Error: \(responseError(error))"
)
}
logger.error("Error loading users \(responseError(error))")
}
}
}
}, label: {
HStack(spacing: 0) {
ProfileImage(imageStr: user.image, size: 44, color: Color(uiColor: .tertiarySystemFill))
.padding(.trailing, 12)
Text(user.chatViewName)
.fontWeight(user.activeUser ? .medium : .regular)
.foregroundColor(theme.colors.onBackground)
.overlay(DetermineWidth())
Spacer()
if user.activeUser {
Image(systemName: "checkmark")
} else if u.unreadCount > 0 {
unreadCounter(u.unreadCount, color: user.showNtfs ? theme.colors.primary : theme.colors.secondary)
} else if !user.showNtfs {
Image(systemName: "speaker.slash")
}
.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)
}
}
.frame(width: size)
.onTapGesture {
switchingProfile = true
Task {
do {
try await changeActiveUserAsync_(u.user.userId, viewPwd: nil)
await MainActor.run {
switchingProfile = false
dismiss()
}
} catch {
await MainActor.run {
switchingProfile = false
AlertManager.shared.showAlertMsg(
title: "Error switching profile!",
message: "Error: \(responseError(error))"
)
}
}
}
.padding(.trailing)
.padding([.leading, .vertical], 12)
})
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
}
}
private func menuButton(_ title: LocalizedStringKey, icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 0) {
Text(title)
.overlay(DetermineWidth())
Spacer()
Image(systemName: icon)
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)
}
.padding(.horizontal)
.padding(.vertical, 22)
.frame(height: menuButtonHeight)
}
.buttonStyle(PressedButtonStyle(defaultColor: theme.colors.surface, pressedColor: Color(uiColor: .secondarySystemFill)))
}, 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())
}
private func unreadBadge(_ u: UserInfo) -> some View {
let size = dynamicSize(userFont).chatInfoSize
return unreadCountText(u.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: size, minHeight: size)
.background(u.user.showNtfs ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
}
}
private func unreadCounter(_ unread: Int, color: Color) -> some View {
unreadCountText(unread)
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(color)
.cornerRadius(10)
}
struct UserPicker_Previews: PreviewProvider {
static var previews: some View {
@State var activeSheet: UserPickerSheet?
let m = ChatModel()
m.users = [UserInfo.sampleData, UserInfo.sampleData]
return UserPicker(
showSettings: Binding.constant(false),
showConnectDesktop: Binding.constant(false),
userPickerVisible: Binding.constant(true)
activeSheet: $activeSheet
)
.environmentObject(m)
}
+28 -2
View File
@@ -8,15 +8,22 @@
import SwiftUI
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
func getTopViewController() -> UIViewController? {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
if let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first,
let rootViewController = keyWindow.rootViewController {
let rootViewController = keyWindow.rootViewController {
// Find the top-most presented view controller
var topController = rootViewController
while let presentedViewController = topController.presentedViewController {
topController = presentedViewController
}
return topController
}
return nil
}
func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
if let topController = getTopViewController() {
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let completed = completed {
activityViewController.completionWithItemsHandler = { _, _, _, _ in
@@ -26,3 +33,22 @@ func showShareSheet(items: [Any], completed: (() -> Void)? = nil) {
topController.present(activityViewController, animated: true)
}
}
func showAlert(
title: String,
message: String? = nil,
buttonTitle: String,
buttonAction: @escaping () -> Void,
cancelButton: Bool
) -> Void {
if let topController = getTopViewController() {
let alert = UIAlertController(title: title, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: buttonTitle, style: .default) { _ in
buttonAction()
})
if cancelButton {
alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel))
}
topController.present(alert, animated: true)
}
}
@@ -24,6 +24,7 @@ private enum MigrationFromState: Equatable {
}
private enum MigrateFromDeviceViewAlert: Identifiable {
case finishMigration(_ fileId: Int64, _ ctrl: chat_ctrl)
case deleteChat(_ title: LocalizedStringKey = "Delete chat profile?", _ text: LocalizedStringKey = "This action cannot be undone - your profile, contacts, messages and files will be irreversibly lost.")
case startChat(_ title: LocalizedStringKey = "Start chat?", _ text: LocalizedStringKey = "Warning: starting chat on multiple devices is not supported and will cause message delivery failures")
@@ -38,6 +39,7 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
var id: String {
switch self {
case .finishMigration: return "finishMigration"
case let .deleteChat(title, text): return "\(title) \(text)"
case let .startChat(title, text): return "\(title) \(text)"
@@ -56,8 +58,6 @@ private enum MigrateFromDeviceViewAlert: Identifiable {
struct MigrateFromDevice: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var showSettings: Bool
@Binding var showProgressOnSettings: Bool
@State private var migrationState: MigrationFromState = .chatStopInProgress
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@@ -106,9 +106,6 @@ struct MigrateFromDevice: View {
finishedView(chatDeletion)
}
}
.modifier(BackButton(label: "Back", disabled: $backDisabled) {
dismiss()
})
.onChange(of: migrationState) { state in
backDisabled = switch migrationState {
case .chatStopInProgress, .archiving, .linkShown, .finished: true
@@ -138,6 +135,15 @@ struct MigrateFromDevice: View {
}
.alert(item: $alert) { alert in
switch alert {
case let .finishMigration(fileId, ctrl):
return Alert(
title: Text("Remove archive?"),
message: Text("The uploaded database archive will be permanently removed from the servers."),
primaryButton: .destructive(Text("Continue")) {
finishMigration(fileId, ctrl)
},
secondaryButton: .cancel()
)
case let .startChat(title, text):
return Alert(
title: Text(title),
@@ -318,7 +324,7 @@ struct MigrateFromDevice: View {
Text("Cancel migration").foregroundColor(.red)
}
}
Button(action: { finishMigration(fileId, ctrl) }) {
Button(action: { alert = .finishMigration(fileId, ctrl) }) {
settingsRow("checkmark", color: theme.colors.secondary) {
Text("Finalize migration").foregroundColor(theme.colors.primary)
}
@@ -590,7 +596,7 @@ struct MigrateFromDevice: View {
} catch let error {
fatalError("Error starting chat \(responseError(error))")
}
showSettings = false
dismissAllSheets(animated: true)
}
} catch let error {
alert = .error(title: "Error deleting database", error: responseError(error))
@@ -613,9 +619,7 @@ struct MigrateFromDevice: View {
}
// Hide settings anyway if chatDbStatus is not ok, probably passphrase needs to be entered
if dismiss || m.chatDbStatus != .ok {
await MainActor.run {
showSettings = false
}
dismissAllSheets(animated: true)
}
}
@@ -767,6 +771,6 @@ private class MigrationChatReceiver {
struct MigrateFromDevice_Previews: PreviewProvider {
static var previews: some View {
MigrateFromDevice(showSettings: Binding.constant(true), showProgressOnSettings: Binding.constant(false))
MigrateFromDevice(showProgressOnSettings: Binding.constant(false))
}
}
@@ -93,7 +93,6 @@ struct MigrateToDevice: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@Binding var migrationState: MigrationToState?
@State private var useKeychain = storeDBPassphraseGroupDefault.get()
@State private var alert: MigrateToDeviceViewAlert?
@@ -102,6 +101,7 @@ struct MigrateToDevice: View {
// Prevent from hiding the view until migration is finished or app deleted
@State private var backDisabled: Bool = false
@State private var showQRCodeScanner: Bool = true
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View {
VStack {
@@ -197,10 +197,8 @@ struct MigrateToDevice: View {
}
}
}
if developerTools {
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
pasteLinkView()
}
Section(header: Text("Or paste archive link").foregroundColor(theme.colors.secondary)) {
pasteLinkView()
}
}
}
@@ -218,7 +216,7 @@ struct MigrateToDevice: View {
} label: {
Text("Tap to paste link")
}
.disabled(!ChatModel.shared.pasteboardHasStrings)
.disabled(!pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
}
@@ -487,6 +485,9 @@ struct MigrateToDevice: View {
do {
if !hasChatCtrl() {
chatInitControllerRemovingDatabases()
} else if ChatModel.shared.chatRunning == true {
// cannot delete storage if chat is running
try await apiStopChat()
}
try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
@@ -556,11 +557,22 @@ struct MigrateToDevice: View {
do {
try? FileManager.default.removeItem(at: getMigrationTempFilesDirectory())
MigrationToDeviceState.save(nil)
appSettings.importIntoApp()
try SimpleX.startChat(refreshInvitations: true)
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
try ObjC.catchException {
appSettings.importIntoApp()
}
do {
try SimpleX.startChat(refreshInvitations: true)
AlertManager.shared.showAlertMsg(title: "Chat migrated!", message: "Finalize migration on another device.")
} catch let error {
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
}
} catch let error {
AlertManager.shared.showAlert(Alert(title: Text("Error starting chat"), message: Text(responseError(error))))
logger.error("Error importing settings: \(error.localizedDescription)")
AlertManager.shared.showAlert(
Alert(
title: Text("Error migrating settings"),
message: Text ("Not all settings were migrated. Repeat migration if you need them.") + Text("\n\n") + Text(responseError(error)))
)
}
hideView()
}
@@ -585,6 +585,7 @@ private struct ConnectView: View {
@Binding var pastedLink: String
@Binding var alert: NewChatViewAlert?
@State private var sheet: PlanAndConnectActionSheet?
@State private var pasteboardHasStrings = UIPasteboard.general.hasStrings
var body: some View {
List {
@@ -621,7 +622,7 @@ private struct ConnectView: View {
} label: {
Text("Tap to paste link")
}
.disabled(!ChatModel.shared.pasteboardHasStrings)
.disabled(!pasteboardHasStrings)
.frame(maxWidth: .infinity, alignment: .center)
} else {
linkTextView(pastedLink)
@@ -59,13 +59,6 @@ struct ConnectDesktopView: View {
var body: some View {
if viaSettings {
viewBody
.modifier(BackButton(label: "Back", disabled: Binding.constant(false)) {
if m.activeRemoteCtrl {
alert = .disconnectDesktop(action: .back)
} else {
dismiss()
}
})
} else {
NavigationView {
viewBody
@@ -52,10 +52,10 @@ extension AppSettings {
profileImageCornerRadiusGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
}
if let val = uiColorScheme { def.setValue(val, forKey: DEFAULT_CURRENT_THEME) }
if let val = uiDarkColorScheme { def.setValue(val, forKey: DEFAULT_SYSTEM_DARK_THEME) }
if let val = uiCurrentThemeIds { def.setValue(val, forKey: DEFAULT_CURRENT_THEME_IDS) }
if let val = uiThemes { def.setValue(val.skipDuplicates(), forKey: DEFAULT_THEME_OVERRIDES) }
if let val = uiColorScheme { currentThemeDefault.set(val) }
if let val = uiDarkColorScheme { systemDarkThemeDefault.set(val) }
if let val = uiCurrentThemeIds { currentThemeIdsDefault.set(val) }
if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) }
if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) }
}
@@ -32,6 +32,17 @@ struct PreferencesView: View {
.disabled(currentPreferences == preferences)
}
}
.onDisappear {
if currentPreferences != preferences {
showAlert(
title: NSLocalizedString("Your chat preferences", comment: "alert title"),
message: NSLocalizedString("Chat preferences were changed.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: savePreferences,
cancelButton: true
)
}
}
}
private func featureSection(_ feature: ChatFeature, _ allowFeature: Binding<FeatureAllowed>) -> some View {
@@ -262,7 +262,9 @@ struct SettingsView: View {
var body: some View {
ZStack {
settingsView()
NavigationView {
settingsView()
}
if showProgress {
progressView()
}
@@ -274,63 +276,7 @@ struct SettingsView: View {
@ViewBuilder func settingsView() -> some View {
let user = chatModel.currentUser
NavigationView {
List {
Section(header: Text("You").foregroundColor(theme.colors.secondary)) {
if let user = user {
NavigationLink {
UserProfile()
.navigationTitle("Your current profile")
.modifier(ThemedBackground())
} label: {
ProfilePreview(profileOf: user)
.padding(.leading, -8)
}
}
NavigationLink {
UserProfilesView(showSettings: $showSettings)
} label: {
settingsRow("person.crop.rectangle.stack", color: theme.colors.secondary) { Text("Your chat profiles") }
}
if let user = user {
NavigationLink {
UserAddressView(shareViaProfile: user.addressShared)
.navigationTitle("SimpleX address")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("qrcode", color: theme.colors.secondary) { Text("Your SimpleX address") }
}
NavigationLink {
PreferencesView(profile: user.profile, preferences: user.fullPreferences, currentPreferences: user.fullPreferences)
.navigationTitle("Your preferences")
.modifier(ThemedBackground(grouped: true))
} label: {
settingsRow("switch.2", color: theme.colors.secondary) { Text("Chat preferences") }
}
}
NavigationLink {
ConnectDesktopView(viaSettings: true)
} label: {
settingsRow("desktopcomputer", color: theme.colors.secondary) { Text("Use from desktop") }
}
NavigationLink {
MigrateFromDevice(showSettings: $showSettings, showProgressOnSettings: $showProgress)
.navigationTitle("Migrate device")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
}
}
.disabled(chatModel.chatRunning != true)
Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
NavigationLink {
NotificationsView()
@@ -381,10 +327,20 @@ struct SettingsView: View {
}
.disabled(chatModel.chatRunning != true)
}
chatDatabaseRow()
}
Section(header: Text("Chat database").foregroundColor(theme.colors.secondary)) {
chatDatabaseRow()
NavigationLink {
MigrateFromDevice(showProgressOnSettings: $showProgress)
.navigationTitle("Migrate device")
.modifier(ThemedBackground(grouped: true))
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("tray.and.arrow.up", color: theme.colors.secondary) { Text("Migrate to another device") }
}
}
Section(header: Text("Help").foregroundColor(theme.colors.secondary)) {
if let user = user {
NavigationLink {
@@ -462,11 +418,10 @@ struct SettingsView: View {
}
.navigationTitle("Your settings")
.modifier(ThemedBackground(grouped: true))
}
.onDisappear {
chatModel.showingTerminal = false
chatModel.terminalItems = []
}
.onDisappear {
chatModel.showingTerminal = false
chatModel.terminalItems = []
}
}
private func chatDatabaseRow() -> some View {
@@ -549,17 +504,18 @@ struct ProfilePreview: View {
HStack {
ProfileImage(imageStr: profileOf.image, size: 44, color: color)
.padding(.trailing, 6)
.padding(.vertical, 6)
VStack(alignment: .leading) {
Text(profileOf.displayName)
.fontWeight(.bold)
.font(.title2)
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
Text(profileOf.fullName)
}
}
profileName().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 + ")")
// .font(.callout)
}
return t
}
}
struct SettingsView_Previews: PreviewProvider {
@@ -14,7 +14,6 @@ struct UserAddressView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject private var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State var viaCreateLinkView = false
@State var shareViaProfile = false
@State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState()
@@ -22,7 +21,6 @@ struct UserAddressView: View {
@State private var showMailView = false
@State private var mailViewResult: Result<MFMailComposeResult, Error>? = nil
@State private var alert: UserAddressAlert?
@State private var showSaveDialogue = false
@State private var progressIndicator = false
@FocusState private var keyboardVisible: Bool
@@ -44,26 +42,19 @@ struct UserAddressView: View {
var body: some View {
ZStack {
if viaCreateLinkView {
userAddressScrollView()
} else {
userAddressScrollView()
.modifier(BackButton(disabled: Binding.constant(false)) {
if savedAAS == aas {
dismiss()
} else {
keyboardVisible = false
showSaveDialogue = true
}
})
.confirmationDialog("Save settings?", isPresented: $showSaveDialogue) {
Button("Save auto-accept settings") {
saveAAS()
dismiss()
}
Button("Exit without saving") { dismiss() }
userAddressScrollView()
.onDisappear {
if savedAAS != aas {
showAlert(
title: NSLocalizedString("Auto-accept settings", comment: "alert title"),
message: NSLocalizedString("Settings were changed.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: saveAAS,
cancelButton: true
)
}
}
}
if progressIndicator {
ZStack {
if chatModel.userAddress != nil {
@@ -238,7 +229,7 @@ struct UserAddressView: View {
}
}
} label: {
Label("Create SimpleX address", systemImage: "qrcode")
Label("Create public address", systemImage: "qrcode")
}
}
@@ -342,7 +333,7 @@ struct UserAddressView: View {
}
}
}
private struct AutoAcceptState: Equatable {
var enable = false
var incognito = false
@@ -447,6 +438,8 @@ struct UserAddressView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")
return Group {
UserAddressView()
.environmentObject(chatModel)
@@ -9,7 +9,6 @@ import SimpleXChat
struct UserProfilesView: View {
@EnvironmentObject private var m: ChatModel
@EnvironmentObject private var theme: AppTheme
@Binding var showSettings: Bool
@Environment(\.editMode) private var editMode
@AppStorage(DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE) private var showHiddenProfilesNotice = true
@AppStorage(DEFAULT_SHOW_MUTE_PROFILE_ALERT) private var showMuteProfileAlert = true
@@ -96,8 +95,7 @@ struct UserProfilesView: View {
} label: {
Label("Add profile", systemImage: "plus")
}
.frame(height: 44)
.padding(.vertical, 4)
.frame(height: 38)
}
} footer: {
Text("Tap to activate profile.")
@@ -285,7 +283,7 @@ struct UserProfilesView: View {
await MainActor.run {
onboardingStageDefault.set(.step1_SimpleXInfo)
m.onboardingStage = .step1_SimpleXInfo
showSettings = false
dismissAllSheets()
}
}
} else {
@@ -308,14 +306,14 @@ struct UserProfilesView: View {
Task {
do {
try await changeActiveUserAsync_(user.userId, viewPwd: userViewPassword(user))
dismissAllSheets()
} catch {
await MainActor.run { alert = .activateUserError(error: responseError(error)) }
}
}
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 44)
.padding(.vertical, 4)
ProfileImage(imageStr: user.image, size: 38)
.padding(.trailing, 12)
Text(user.chatViewName)
Spacer()
@@ -415,6 +413,6 @@ public func correctPassword(_ user: User, _ pwd: String) -> Bool {
struct UserProfilesView_Previews: PreviewProvider {
static var previews: some View {
UserProfilesView(showSettings: Binding.constant(true))
UserProfilesView()
}
}
+28 -20
View File
@@ -167,6 +167,11 @@
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */; };
64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085E2C8F2CCB00AE9D01 /* libffi.a */; };
64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A2085F2C8F2CCB00AE9D01 /* libgmp.a */; };
64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */; };
64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
@@ -177,6 +182,8 @@
64E972072881BB22008DBC02 /* CIGroupInvitationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */; };
64EEB0F72C353F1C00972D62 /* ServersSummaryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */; };
64F1CC3B28B39D8600CD1FB1 /* IncognitoHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */; };
8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */ = {isa = PBXBuildFile; fileRef = 8C01E9C02C8EFC33008A4B0A /* objc.m */; };
8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */ = {isa = PBXBuildFile; fileRef = 8C01E9BF2C8EFBB6008A4B0A /* objc.h */; };
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */; };
8C74C3E52C1B900600039E77 /* ThemeTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C7E3CE32C0DEAC400BFF63A /* ThemeTypes.swift */; };
8C74C3E72C1B901900039E77 /* Color.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C852B072C1086D100BA61E8 /* Color.swift */; };
@@ -215,11 +222,6 @@
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
E51CC1E62C62085600DB91FE /* OneHandUICard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E51CC1E52C62085600DB91FE /* OneHandUICard.swift */; };
E5BD84572C832BF9008C24D1 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84522C832BF9008C24D1 /* libgmpxx.a */; };
E5BD84582C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */; };
E5BD84592C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */; };
E5BD845A2C832BF9008C24D1 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84552C832BF9008C24D1 /* libgmp.a */; };
E5BD845B2C832BF9008C24D1 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E5BD84562C832BF9008C24D1 /* libffi.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 */; };
@@ -506,6 +508,11 @@
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64A2085E2C8F2CCB00AE9D01 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64A2085F2C8F2CCB00AE9D01 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a"; sourceTree = "<group>"; };
64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a"; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
@@ -517,6 +524,8 @@
64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupInvitationView.swift; sourceTree = "<group>"; };
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServersSummaryView.swift; sourceTree = "<group>"; };
64F1CC3A28B39D8600CD1FB1 /* IncognitoHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncognitoHelp.swift; sourceTree = "<group>"; };
8C01E9BF2C8EFBB6008A4B0A /* objc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = objc.h; sourceTree = "<group>"; };
8C01E9C02C8EFC33008A4B0A /* objc.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = objc.m; sourceTree = "<group>"; };
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
8C74C3EB2C1B92A900039E77 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
8C74C3ED2C1B942300039E77 /* ChatWallpaper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWallpaper.swift; sourceTree = "<group>"; };
@@ -552,11 +561,6 @@
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; };
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneHandUICard.swift; sourceTree = "<group>"; };
E5BD84522C832BF9008C24D1 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a"; sourceTree = "<group>"; };
E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a"; sourceTree = "<group>"; };
E5BD84552C832BF9008C24D1 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
E5BD84562C832BF9008C24D1 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.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>"; };
@@ -647,13 +651,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E5BD84572C832BF9008C24D1 /* libgmpxx.a in Frameworks */,
E5BD845A2C832BF9008C24D1 /* libgmp.a in Frameworks */,
64A208662C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a in Frameworks */,
64A208622C8F2CCC00AE9D01 /* libgmpxx.a in Frameworks */,
64A208652C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
E5BD845B2C832BF9008C24D1 /* libffi.a in Frameworks */,
E5BD84582C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a in Frameworks */,
E5BD84592C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
64A208632C8F2CCC00AE9D01 /* libffi.a in Frameworks */,
64A208642C8F2CCC00AE9D01 /* libgmp.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -731,11 +735,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
E5BD84562C832BF9008C24D1 /* libffi.a */,
E5BD84552C832BF9008C24D1 /* libgmp.a */,
E5BD84522C832BF9008C24D1 /* libgmpxx.a */,
E5BD84542C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N-ghc9.6.3.a */,
E5BD84532C832BF9008C24D1 /* libHSsimplex-chat-6.1.0.0-1tXK6wuT4H71iMwoWdPa4N.a */,
64A2085E2C8F2CCB00AE9D01 /* libffi.a */,
64A2085F2C8F2CCB00AE9D01 /* libgmp.a */,
64A2085D2C8F2CCB00AE9D01 /* libgmpxx.a */,
64A208602C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7-ghc9.6.3.a */,
64A208612C8F2CCC00AE9D01 /* libHSsimplex-chat-6.1.0.0-G2fNFSEVU486aN7U9YSuO7.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -979,6 +983,8 @@
5CE2BA96284537A800EC33A6 /* dummy.m */,
5CD67B8D2B0E858A00C510B1 /* hs_init.h */,
5CD67B8E2B0E858A00C510B1 /* hs_init.c */,
8C01E9BF2C8EFBB6008A4B0A /* objc.h */,
8C01E9C02C8EFC33008A4B0A /* objc.m */,
);
path = SimpleXChat;
sourceTree = "<group>";
@@ -1116,6 +1122,7 @@
files = (
5CE2BA77284530BF00EC33A6 /* SimpleXChat.h in Headers */,
5CD67B8F2B0E858A00C510B1 /* hs_init.h in Headers */,
8C01E9C22C8EFF8F008A4B0A /* objc.h in Headers */,
5CE2BA952845354B00EC33A6 /* SimpleX.h in Headers */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -1543,6 +1550,7 @@
8C74C3E72C1B901900039E77 /* Color.swift in Sources */,
5CD67B902B0E858A00C510B1 /* hs_init.c in Sources */,
5CE2BA91284533A300EC33A6 /* Notifications.swift in Sources */,
8C01E9C12C8EFC33008A4B0A /* objc.m in Sources */,
5CE2BA79284530CC00EC33A6 /* SimpleXChat.docc in Sources */,
5CE2BA90284533A300EC33A6 /* JSON.swift in Sources */,
5CE2BA8B284533A300EC33A6 /* ChatTypes.swift in Sources */,
+7
View File
@@ -129,6 +129,7 @@ public enum ChatCommand {
// WebRTC calls /
case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case apiChatItemsRead(type: ChatType, id: Int64, itemIds: [Int64])
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool?)
@@ -293,6 +294,7 @@ public enum ChatCommand {
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
case .apiGetNetworkStatuses: return "/_network_statuses"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .apiChatItemsRead(type, id, itemIds): return "/_read chat items \(ref(type, id)) \(joinedIds(itemIds))"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
case let .receiveFile(fileId, userApprovedRelays, encrypt, inline): return "/freceive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
case let .setFileToReceive(fileId, userApprovedRelays, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("approved_relays", userApprovedRelays))\(onOffParam("encrypt", encrypt))"
@@ -434,6 +436,7 @@ public enum ChatCommand {
case .apiCallStatus: return "apiCallStatus"
case .apiGetNetworkStatuses: return "apiGetNetworkStatuses"
case .apiChatRead: return "apiChatRead"
case .apiChatItemsRead: return "apiChatItemsRead"
case .apiChatUnread: return "apiChatUnread"
case .receiveFile: return "receiveFile"
case .setFileToReceive: return "setFileToReceive"
@@ -462,6 +465,10 @@ public enum ChatCommand {
"\(type.rawValue)\(id)"
}
func joinedIds(_ ids: [Int64]) -> String {
ids.map { "\($0)" }.joined(separator: ",")
}
func protoServersStr(_ servers: [ServerCfg]) -> String {
encodeJSON(ProtoServersConfig(servers: servers))
}
+5 -1
View File
@@ -391,7 +391,9 @@ public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
return img
} else if let data = Data(base64Encoded: dropImagePrefix(base64Encoded)),
let img = UIImage(data: data) {
imageCache.setObject(img, forKey: base64Encoded as NSString)
imageCacheQueue.async {
imageCache.setObject(img, forKey: base64Encoded as NSString)
}
return img
} else {
return nil
@@ -401,6 +403,8 @@ public func imageFromBase64(_ base64Encoded: String?) -> UIImage? {
}
}
private let imageCacheQueue = DispatchQueue.global(qos: .background)
private var imageCache: NSCache<NSString, UIImage> = {
var cache = NSCache<NSString, UIImage>()
cache.countLimit = 1000
+1
View File
@@ -10,6 +10,7 @@
#define SimpleX_h
#include "hs_init.h"
#include "objc.h"
extern void hs_init(int argc, char **argv[]);
+20
View File
@@ -0,0 +1,20 @@
//
// objc.h
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 09.09.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
#ifndef objc_h
#define objc_h
#import <Foundation/Foundation.h>
@interface ObjC : NSObject
+ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error;
@end
#endif /* objc_h */
+25
View File
@@ -0,0 +1,25 @@
//
// objc.m
// SimpleXChat
//
// Created by Stanislav Dmitrenko on 09.09.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
#import "objc.h"
@implementation ObjC
// https://stackoverflow.com/a/36454808
+ (BOOL)catchException:(void(^)(void))tryBlock error:(__autoreleasing NSError **)error {
@try {
tryBlock();
return YES;
}
@catch (NSException *exception) {
*error = [[NSError alloc] initWithDomain: exception.name code: 0 userInfo: exception.userInfo];
return NO;
}
}
@end
@@ -32,7 +32,6 @@ android {
}
manifestPlaceholders["app_name"] = "@string/app_name"
manifestPlaceholders["provider_authorities"] = "chat.simplex.app.provider"
manifestPlaceholders["extract_native_libs"] = rootProject.extra["compression.level"] as Int != 0
}
buildTypes {
@@ -75,7 +74,6 @@ android {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0
}
android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets"))
val isRelease = gradle.startParameter.taskNames.find { it.lowercase().contains("release") } != null
@@ -33,7 +33,6 @@
android:fullBackupOnly="false"
android:icon="@mipmap/icon"
android:label="${app_name}"
android:extractNativeLibs="${extract_native_libs}"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<!-- android:localeConfig="@xml/locales_config"-->
@@ -7,6 +7,7 @@ import chat.simplex.common.platform.Log
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.*
import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.Color
@@ -259,7 +260,6 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
override fun androidSetNightModeIfSupported() {
if (Build.VERSION.SDK_INT < 31) return
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) {
null
} else {
@@ -274,6 +274,31 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
uiModeManager.setApplicationNightMode(mode)
}
override fun androidSetDrawerStatusAndNavBarColor(
isLight: Boolean,
drawerShadingColor: Color,
toolbarOnTop: Boolean,
navBarColor: Color,
) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
// Blend status bar color to the animated color
val colors = CurrentColors.value.colors
val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
window.statusBarColor = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
val navBar = navBarColor.toArgb()
if (window.navigationBarColor != navBar) {
window.navigationBarColor = navBar
}
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
windowInsetController?.isAppearanceLightNavigationBars = isLight
}
}
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
+6 -1
View File
@@ -99,10 +99,15 @@ kotlin {
val desktopMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.8.0")
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8")
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.8") {
exclude("net.java.dev.jna")
}
// For jSystemThemeDetector only
implementation("net.java.dev.jna:jna-platform:5.14.0")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.12")
implementation("uk.co.caprica:vlcj:4.8.3")
implementation("net.java.dev.jna:jna:5.14.0")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
@@ -0,0 +1,222 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
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.drawBehind
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.User
import chat.simplex.common.model.UserInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import 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
@Composable
actual fun UserPickerInactiveUsersSection(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
) {
val scrollState = rememberScrollState()
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),
disabled = stopped
) {
Box {
Row(
modifier = Modifier.padding(end = DEFAULT_PADDING + 30.dp).horizontalScroll(scrollState)
) {
users.forEach { u ->
UserPickerInactiveUserBadge(u, stopped) {
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)
)
}
}
}
}
} else {
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
stringResource(MR.strings.your_chat_profiles),
onShowAllProfilesClicked
)
}
}
private fun calculateFraction(pos: Float) =
(pos / 1f).coerceIn(0f, 1f)
@Composable
actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<AnimatedViewState>, content: @Composable () -> Unit) {
val pickerIsVisible = pickerState.collectAsState().value.isVisible()
val dismissState = rememberDismissState(initialValue = if (pickerIsVisible) DismissValue.Default else DismissValue.DismissedToEnd) {
if (it == DismissValue.DismissedToEnd && pickerState.value.isVisible()) {
pickerState.value = AnimatedViewState.HIDING
}
true
}
val height = remember { mutableIntStateOf(0) }
val heightValue = height.intValue
val clickableModifier = if (pickerIsVisible) {
Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING })
} else {
Modifier
}
Box(
Modifier
.fillMaxSize()
.then(clickableModifier)
.drawBehind {
val pos = when {
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
else -> 1 - dismissState.progress.fraction
}
val colors = CurrentColors.value.colors
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
if (pickerState.value.isVisible()) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = colors.surface
)
} else if (ModalManager.start.modalCount.value == 0) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
} else {
colors.background
})
)
}
drawRect(
if (pos != 0f) resultingColor else Color.Transparent,
alpha = calculateFraction(pos = pos)
)
}
.graphicsLayer {
if (heightValue == 0) {
alpha = 0f
}
translationY = dismissState.offset.value
},
contentAlignment = Alignment.BottomCenter
) {
Box(
Modifier.onSizeChanged { height.intValue = it.height }
) {
KeyChangeEffect(pickerIsVisible) {
if (pickerState.value.isVisible()) {
try {
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.GONE
}
} else {
try {
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.VISIBLE
}
}
}
val draggableModifier = if (height.intValue != 0)
Modifier.draggableBottomDrawerModifier(
state = dismissState,
swipeDistance = height.intValue.toFloat(),
)
else Modifier
Box(draggableModifier.then(modifier)) {
content()
}
}
}
}
private fun Modifier.draggableBottomDrawerModifier(
state: DismissState,
swipeDistance: Float,
): Modifier = this.swipeable(
state = state,
anchors = mapOf(0f to DismissValue.Default, swipeDistance to DismissValue.DismissedToEnd),
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Vertical,
resistance = null
)
@@ -6,14 +6,11 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -42,12 +39,6 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.sqrt
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState
)
@Composable
fun AppScreen() {
@@ -145,13 +136,11 @@ fun MainScreen() {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
val scaffoldState = rememberScaffoldState()
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
SetupClipboardListener()
if (appPlatform.isAndroid) {
AndroidScreen(settingsState)
AndroidScreen(userPickerState)
} else {
DesktopScreen(settingsState)
DesktopScreen(userPickerState)
}
}
}
@@ -249,7 +238,7 @@ fun MainScreen() {
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable
fun AndroidScreen(settingsState: SettingsViewState) {
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
@@ -262,7 +251,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) {
StartPartOfScreen(settingsState)
StartPartOfScreen(userPickerState)
}
val scope = rememberCoroutineScope()
val onComposed: suspend (chatId: String?) -> Unit = { chatId ->
@@ -318,15 +307,15 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
@Composable
fun StartPartOfScreen(settingsState: SettingsViewState) {
fun StartPartOfScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel)
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, settingsState, stopped)
ShareListView(chatModel, stopped)
}
}
@@ -367,49 +356,41 @@ fun EndPartOfScreen() {
}
@Composable
fun DesktopScreen(settingsState: SettingsViewState) {
Box {
// 56.dp is a size of unused space of settings drawer
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + 56.dp)) {
StartPartOfScreen(settingsState)
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
scope.launch { settingsState.scaffoldState.drawerState.close() }
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
StartPartOfScreen(userPickerState)
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
}
ModalManager.fullscreen.showInView()
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
userPickerState.value = AnimatedViewState.HIDING
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
ModalManager.fullscreen.showInView()
}
@Composable
@@ -1480,6 +1480,13 @@ object ChatController {
return false
}
suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, itemIds: List<Long>): Boolean {
val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, itemIds))
if (r is CR.CmdOk) return true
Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean {
val r = sendCmd(rh, CC.ApiChatUnread(type, id, unreadChat))
if (r is CR.CmdOk) return true
@@ -2967,6 +2974,7 @@ sealed class CC {
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatItemsRead(val type: ChatType, val id: Long, val itemIds: List<Long>): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val userApprovedRelays: Boolean, val encrypt: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC()
@@ -3123,6 +3131,7 @@ sealed class CC {
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiGetNetworkStatuses -> "/_network_statuses"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatItemsRead -> "/_read chat items ${chatRef(type, id)} ${itemIds.joinToString(",")}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile ->
"/freceive $fileId" +
@@ -3266,6 +3275,7 @@ sealed class CC {
is ApiCallStatus -> "apiCallStatus"
is ApiGetNetworkStatuses -> "apiGetNetworkStatuses"
is ApiChatRead -> "apiChatRead"
is ApiChatItemsRead -> "apiChatItemsRead"
is ApiChatUnread -> "apiChatUnread"
is ReceiveFile -> "receiveFile"
is CancelFile -> "cancelFile"
@@ -1,7 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.*
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
@@ -22,6 +21,7 @@ interface PlatformInterface {
fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
@@ -109,7 +109,6 @@ fun TerminalLayout(
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
@@ -105,6 +105,7 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> {
val perChatTheme = remember(chatInfo, CurrentColors.value.base) { if (chatInfo is ChatInfo.Direct) chatInfo.contact.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.uiThemes?.preferredMode(!CurrentColors.value.colors.isLight) else null }
val overrides = if (perChatTheme != null) ThemeManager.currentColors(null, perChatTheme, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) else null
val fullDeleteAllowed = remember(chatInfo) { chatInfo.featureEnabled(ChatFeature.FullDelete) }
SimpleXThemeOverride(overrides ?: CurrentColors.collectAsState().value) {
ChatLayout(
remoteHostId = remoteHostId,
@@ -142,10 +143,15 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
chatInfo = chatInfo,
deleteItems = { canDeleteForAll ->
val itemIds = selectedChatItems.value
val questionText =
if (!canDeleteForAll || fullDeleteAllowed || chatInfo is ChatInfo.Local)
generalGetString(MR.strings.delete_messages_cannot_be_undone_warning)
else
generalGetString(MR.strings.delete_messages_mark_deleted_warning)
if (itemIds != null) {
deleteMessagesAlertDialog(
itemIds.sorted(),
generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning),
questionText = questionText,
forAll = canDeleteForAll,
deleteMessages = { ids, forAll ->
deleteMessages(chatRh, chatInfo, ids, forAll, moderate = false) {
@@ -654,7 +660,6 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
backgroundColor = Color.Unspecified
) { contentPadding ->
val wallpaperImage = MaterialTheme.wallpaper.type.image
@@ -135,7 +135,7 @@ fun ChatItemView(
}
fun deleteMessageQuestionText(): String {
return if (!sent || fullDeleteAllowed) {
return if (!sent || fullDeleteAllowed || cInfo is ChatInfo.Local) {
generalGetString(MR.strings.delete_message_cannot_be_undone_warning)
} else {
generalGetString(MR.strings.delete_message_mark_deleted_warning)
@@ -637,7 +637,7 @@ fun DeleteItemAction(
}
deleteMessagesAlertDialog(
itemIds,
generalGetString(if (itemIds.size == 1) MR.strings.delete_message_mark_deleted_warning else MR.strings.delete_messages_mark_deleted_warning),
generalGetString(MR.strings.delete_messages_cannot_be_undone_warning),
forAll = false,
deleteMessages = { ids, _ -> deleteMessages(ids) }
)
@@ -23,7 +23,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
@@ -135,7 +135,7 @@ fun ToggleChatListCard() {
}
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
@@ -153,18 +153,15 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
VideoPlayerHolder.stopAll()
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(
topBar = {
if (!oneHandUI.value) {
Column(Modifier.padding(end = endPadding)) {
Column {
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped,
setPerformLA,
)
Divider()
}
@@ -172,33 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
},
bottomBar = {
if (oneHandUI.value) {
Column(Modifier.padding(end = endPadding)) {
Column {
Divider()
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped,
)
}
}
},
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(showClose = appPlatform.isDesktop, close = { scope.launch { scaffoldState.drawerState.close() } }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
setPerformLA,
)
}
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
@@ -208,7 +189,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
},
Modifier
.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp)
.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
.size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
@@ -224,7 +205,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
Box(Modifier.padding(it)) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -252,11 +233,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
UserPicker(
chatModel = chatModel,
userPickerState = userPickerState,
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart
) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
setPerformLA = AppLock::setPerformLA
)
}
}
}
@@ -278,7 +256,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value
@@ -344,23 +322,22 @@ private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableSt
}
}
}
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
DefaultTopAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
NavigationButtonMenu {
ModalManager.start.showModalCloseable { close ->
SettingsView(chatModel, setPerformLA, close)
}
}
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
}
},
@@ -11,31 +11,24 @@ import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.newchat.ActiveProfilePicker
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val oneHandUI = remember { appPrefs.oneHandUI.state }
Scaffold(
Modifier.padding(end = endPadding),
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
scaffoldState = scaffoldState,
topBar = {
if (!oneHandUI.value) {
Column {
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
Divider()
}
}
@@ -44,7 +37,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
if (oneHandUI.value) {
Column {
Divider()
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
}
}
}
@@ -92,21 +85,6 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(
chatModel,
userPickerState,
showSettings = false,
showCancel = true,
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart,
cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
}
)
}
}
}
private fun hasSimplexLink(msg: String): Boolean {
@@ -122,7 +100,7 @@ private fun EmptyList() {
}
@Composable
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
@@ -138,7 +116,24 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
userPickerState.value = AnimatedViewState.VISIBLE
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = {
ActiveProfilePicker(
search = search,
rhId = chatModel.remoteHostId,
close = close,
contactConnection = null,
showIncognito = false
)
}
)
}
}
}
else -> NavigationButtonBack(onButtonClicked = {
@@ -1,53 +1,51 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.animation.core.*
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.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import dev.icerock.moko.resources.compose.painterResource
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.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
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.doWithAuth
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.roundToInt
@Composable
fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
showSettings: Boolean = true,
contentAlignment: Alignment = Alignment.TopStart,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
useFromDesktopClicked: () -> Unit = {},
settingsClicked: () -> Unit = {},
setPerformLA: (Boolean) -> Unit,
) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
if (newChat.isVisible()) {
BackHandler {
@@ -67,18 +65,32 @@ fun UserPicker(
.sortedBy { it.hostDeviceName }
}
}
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
launch {
userPickerState.collect {
newChat = it
if (it.isVisible()) {
hideKeyboard(view)
}
launch {
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
}
}
}
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { ModalManager.start.modalCount.value }
.filter { it > 0 }
.collect {
closePicker(userPickerState)
}
}
}
LaunchedEffect(Unit) {
snapshotFlow { newChat.isVisible() }
.distinctUntilChanged()
@@ -124,110 +136,143 @@ fun UserPicker(
}
}
}
val UsersView: @Composable ColumnScope.() -> Unit = {
users.forEach { u ->
UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) {
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
withBGApi {
controller.showProgressIfNeeded {
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
}
}
}
}
Divider(Modifier.requiredHeight(1.dp))
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Box(Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
.padding(bottom = 10.dp, top = 10.dp)
.graphicsLayer {
alpha = animatedFloat.value
translationY = (if (appPrefs.oneHandUI.state.value) -1 else 1) * (animatedFloat.value - 1) * xOffset
},
contentAlignment = contentAlignment
PlatformUserPicker(
modifier = Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
.background(MaterialTheme.colors.surface)
.padding(vertical = DEFAULT_PADDING),
pickerState = userPickerState
) {
Column(
Modifier
.widthIn(min = 260.dp)
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
) {
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
}
Divider(Modifier.requiredHeight(1.dp))
} else if (currentRemoteHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = {
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
}
@Composable
fun FirstSection() {
if (remoteHosts.isNotEmpty()) {
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
val localDeviceActive = currentRemoteHost == null && chatModel.localUserCreated.value == true
UsersView()
if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(false) {
DevicePickerRow(
localDeviceActive = localDeviceActive,
remoteHosts = remoteHosts,
onRemoteHostClick = { h, connecting ->
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, connecting)
},
onLocalDeviceClick = {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
},
onRemoteHostActionButtonClick = { h ->
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(h, true)
}
)
}
ActiveUserSection(
chatModel = chatModel,
userPickerState = userPickerState,
)
}
@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) })
}
}
Divider(Modifier.requiredHeight(1.dp))
}
remoteHosts.filter { !it.activeHost }.forEach { h ->
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(h,
actionButtonClick = {
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(h, false)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
)
}
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()
}
}
if (appPlatform.isAndroid) {
UseFromDesktopPickerItem {
ModalManager.start.showCustomModal { close ->
ConnectDesktopView(close)
}
userPickerState.value = AnimatedViewState.GONE
}
Divider(Modifier.requiredHeight(1.dp))
} else {
if (remoteHosts.isEmpty()) {
LinkAMobilePickerItem {
ModalManager.start.showModal {
ConnectMobileView()
}
userPickerState.value = AnimatedViewState.GONE
}
Divider(Modifier.requiredHeight(1.dp))
}
if (chatModel.desktopNoUserNoRemote) {
CreateInitialProfile {
}
}
}
@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_public_contact_address) else generalGetString(MR.strings.create_public_contact_address),
showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped
)
UserPickerOptionRow(
painterResource(MR.images.ic_toggle_on),
stringResource(MR.strings.chat_preferences),
click = if (stopped) null else ({
showCustomModal { m, close ->
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
}()
}),
disabled = stopped
)
}
} else {
SectionView {
if (chatModel.desktopNoUserNoRemote) {
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
generalGetString(MR.strings.create_chat_profile),
{
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
@@ -237,15 +282,76 @@ fun UserPicker(
}
}
}
Divider(Modifier.requiredHeight(1.dp))
)
}
}
}
}
@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 }
}
}
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)
UserPickerOptionRow(
painterResource(MR.images.ic_desktop),
text,
click = {
ModalManager.start.showCustomModal { close ->
ConnectDesktopView(close)
}
}
}
if (showSettings) {
SettingsPickerItem(settingsClicked)
}
if (showCancel) {
CancelPickerItem(cancelClicked)
}
)
} 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(
click = {
ModalManager.start.showModalCloseable { close ->
SettingsView(chatModel, setPerformLA, close)
}
},
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()
}
}
}
@@ -296,7 +402,7 @@ fun UserProfilePickerItem(
}
} else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else {
} else {
Box(Modifier.size(20.dp))
}
}
@@ -325,136 +431,157 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr
}
@Composable
fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.onRightClick { onLongClick() }
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
RemoteHostRow(h)
if (h.sessionState is RemoteHostSessionState.Connected) {
HostDisconnectButton(actionButtonClick)
} else {
Box(Modifier.size(20.dp))
fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false) {
SectionItemView(click, disabled = disabled, extraPadding = true) {
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)
}
}
}
}
}
@Composable
fun RemoteHostRow(h: RemoteHostInfo) {
Row(
Modifier
.widthIn(max = windowWidth() * 0.7f)
.padding(start = 17.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Text(
h.hostDeviceName,
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
color = if (h.activeHost) MaterialTheme.colors.onBackground else MenuTextColor,
fontSize = 14.sp,
)
}
}
@Composable
fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) {
private fun DevicePickerRow(
localDeviceActive: Boolean,
remoteHosts: List<RemoteHostInfo>,
onLocalDeviceClick: () -> Unit,
onRemoteHostClick: (rh: RemoteHostInfo, connecting: MutableState<Boolean>) -> Unit,
onRemoteHostActionButtonClick: (rh: RemoteHostInfo) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
.combinedClickable(
onClick = if (active) {{}} else onClick,
onLongClick = onLongClick,
interactionSource = remember { MutableInteractionSource() },
indication = if (!active) LocalIndication.current else null
)
.onRightClick { onLongClick() }
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
LocalDeviceRow(active)
Box(Modifier.size(20.dp))
val activeHost = remoteHosts.firstOrNull { h -> h.activeHost }
if (activeHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) }
DevicePill(
active = true,
icon = painterResource(MR.images.ic_smartphone_300),
text = activeHost.hostDeviceName,
actionButtonVisible = activeHost.sessionState is RemoteHostSessionState.Connected,
onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) }
) {
onRemoteHostClick(activeHost, connecting)
}
}
DevicePill(
active = localDeviceActive,
icon = painterResource(MR.images.ic_desktop),
text = stringResource(MR.strings.this_device),
actionButtonVisible = false
) {
onLocalDeviceClick()
}
remoteHosts.filter { h -> h.sessionState is RemoteHostSessionState.Connected && !h.activeHost }.forEach { h ->
val connecting = rememberSaveable { mutableStateOf(false) }
DevicePill(
active = h.activeHost,
icon = painterResource(MR.images.ic_smartphone_300),
text = h.hostDeviceName,
actionButtonVisible = h.sessionState is RemoteHostSessionState.Connected,
onActionButtonClick = { onRemoteHostActionButtonClick(h) }
) {
onRemoteHostClick(h, connecting)
}
}
}
}
@Composable
fun LocalDeviceRow(active: Boolean) {
expect fun UserPickerInactiveUsersSection(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
)
@Composable
expect fun PlatformUserPicker(
modifier: Modifier,
pickerState: MutableStateFlow<AnimatedViewState>,
content: @Composable () -> Unit
)
@Composable
fun DevicePill(
active: Boolean,
icon: Painter,
text: String,
actionButtonVisible: Boolean,
onActionButtonClick: (() -> Unit)? = null,
onClick: () -> Unit) {
Row(
Modifier
.widthIn(max = windowWidth() * 0.7f)
.padding(start = 17.dp, end = DEFAULT_PADDING),
.clip(RoundedCornerShape(8.dp))
.border(
BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant),
shape = RoundedCornerShape(8.dp)
)
.background(if (active) MaterialTheme.colors.secondaryVariant else Color.Transparent)
.clickable(
enabled = !active,
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current
),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Text(
stringResource(MR.strings.this_device),
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
color = if (active) MaterialTheme.colors.onBackground else MenuTextColor,
fontSize = 14.sp,
)
}
}
@Composable
private fun UseFromDesktopPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun LinkAMobilePickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.link_a_mobile)
Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun CreateInitialProfile(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.create_chat_profile)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun CancelPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.cancel_verb)
Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
Row(
Modifier.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Icon(
icon,
text,
Modifier.size(16.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.onSurface
)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
Text(
text,
color = MaterialTheme.colors.onSurface,
fontSize = 12.sp,
)
if (onActionButtonClick != null && actionButtonVisible) {
val interactionSource = remember { MutableInteractionSource() }
val hovered = interactionSource.collectIsHoveredAsState().value
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
IconButton(onActionButtonClick, Modifier.requiredSize(16.dp * fontSizeSqrtMultiplier)) {
Icon(
painterResource(if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi),
null,
Modifier.size(16.dp * fontSizeSqrtMultiplier).hoverable(interactionSource),
tint = if (hovered) WarningOrange else MaterialTheme.colors.onBackground
)
}
}
}
}
}
@@ -472,6 +599,29 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) {
}
}
@Composable
private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: 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())
.background(if (userMuted) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 2.sp.toDp())
.padding(vertical = 2.sp.toDp())
.align(Alignment.TopEnd)
)
}
private suspend fun closePicker(userPickerState: MutableStateFlow<AnimatedViewState>) {
delay(500)
userPickerState.value = AnimatedViewState.HIDING
}
private fun switchToLocalDevice() {
withBGApi {
chatController.switchUIRemoteHost(null)
@@ -7,3 +7,5 @@ fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOu
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
fun <T> userPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.DevicePill
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@@ -157,9 +158,13 @@ private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?
@Composable
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary)
Spacer(Modifier.width(10.dp))
Text(hostDevice.second, color = MaterialTheme.colors.secondary)
DevicePill(
active = true,
onClick = {},
actionButtonVisible = false,
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
text = hostDevice.second
)
}
}
@@ -588,9 +588,11 @@ fun <T> KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != prevKey) {
block(prevKey)
val prev = prevKey
prevKey = key1
anyChange = true
// Call it as the last statement because the coroutine can be cancelled earlier
block(prev)
}
}
}
@@ -610,8 +612,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2) {
if (anyChange || key1 != initialKey || key2 != initialKey2) {
block()
anyChange = true
block()
}
}
}
@@ -633,8 +635,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2, key3) {
if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) {
block()
anyChange = true
block()
}
}
}
@@ -349,7 +349,15 @@ private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: S
text = stringResource(MR.strings.migrate_from_device_finalize_migration),
textColor = MaterialTheme.colors.primary,
click = {
finishMigration(fileId, ctrl)
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.migrate_from_device_remove_archive_question),
text = generalGetString(MR.strings.migrate_from_device_uploaded_archive_will_be_removed),
confirmText = generalGetString(MR.strings.continue_to_next_step),
destructive = true,
onConfirm = {
finishMigration(fileId, ctrl)
}
)
}
) {}
SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_archive_will_be_deleted))
@@ -199,10 +199,8 @@ private fun MutableState<MigrationToState?>.PasteOrScanLinkView() {
SectionSpacer()
}
if (appPlatform.isDesktop || appPreferences.developerTools.get()) {
SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) {
PasteLinkView()
}
SectionView(stringResource(if (appPlatform.isAndroid) MR.strings.or_paste_archive_link else MR.strings.paste_archive_link).uppercase()) {
PasteLinkView()
}
}
@@ -561,7 +559,7 @@ private fun MutableState<MigrationToState?>.startDownloading(
)
state = MigrationToState.DownloadFailed(totalBytes, link, archivePath, netCfg)
} else {
Log.d(TAG, "unsupported error: ${msg.responseType}")
Log.d(TAG, "unsupported error: ${msg.responseType}, ${json.encodeToString(msg.chatError)}")
}
}
else -> Log.d(TAG, "unsupported event: ${msg.responseType}")
@@ -2,7 +2,6 @@ package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import TextIconSpaced
@@ -267,24 +266,13 @@ private fun ProfilePickerOption(
)
}
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return users.filter { u ->
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
true
} else {
correctPassword(u, s)
}
}
}
@Composable
private fun ActiveProfilePicker(
fun ActiveProfilePicker(
search: MutableState<String>,
contactConnection: PendingContactConnection?,
close: () -> Unit,
rhId: Long?
rhId: Long?,
showIncognito: Boolean = true
) {
val switchingProfile = remember { mutableStateOf(false) }
val incognito = remember {
@@ -292,11 +280,9 @@ private fun ActiveProfilePicker(
}
val selectedProfile by remember { chatModel.currentUser }
val searchTextOrPassword = rememberSaveable { search }
val profiles = remember {
chatModel.users.map { it.user }.sortedBy { !it.activeUser }
}
val filteredProfiles by remember {
derivedStateOf { filteredProfiles(profiles, searchTextOrPassword.value) }
// Intentionally don't use derivedStateOf in order to NOT change an order after user was selected
val filteredProfiles = remember(searchTextOrPassword.value) {
filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
}
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
@@ -322,32 +308,38 @@ private fun ActiveProfilePicker(
switchingProfile.value = true
withApi {
try {
var updatedConn: PendingContactConnection? = null;
if (contactConnection != null) {
val conn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
if (conn != null) {
updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
if (updatedConn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
updateContactConnection(rhId, updatedConn)
updateShownConnection(updatedConn)
}
controller.changeActiveUser_(
rhId = user.remoteHostId,
toUserId = user.userId,
viewPwd = if (user.hidden) searchTextOrPassword.value else null
)
if (chatModel.currentUser.value?.userId != user.userId) {
AlertManager.shared.showAlertMsg(generalGetString(
MR.strings.switching_profile_error_title),
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
)
}
withChats {
updateContactConnection(user.remoteHostId, conn)
}
close.invoke()
}
}
controller.changeActiveUser_(
rhId = user.remoteHostId,
toUserId = user.userId,
viewPwd = if (user.hidden) searchTextOrPassword.value else null
)
if (chatModel.currentUser.value?.userId != user.userId) {
AlertManager.shared.showAlertMsg(generalGetString(
MR.strings.switching_profile_error_title),
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
)
}
if (updatedConn != null) {
withChats {
updateContactConnection(user.remoteHostId, updatedConn)
}
}
close()
} finally {
switchingProfile.value = false
}
@@ -364,24 +356,21 @@ private fun ActiveProfilePicker(
title = stringResource(MR.strings.incognito),
selected = incognito,
onSelected = {
if (!incognito) {
switchingProfile.value = true
withApi {
try {
if (contactConnection != null) {
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
if (incognito || switchingProfile.value || contactConnection == null) return@ProfilePickerOption
if (conn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
}
close.invoke()
}
switchingProfile.value = true
withApi {
try {
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
if (conn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
}
} finally {
switchingProfile.value = false
close()
}
} finally {
switchingProfile.value = false
}
}
},
@@ -413,20 +402,18 @@ private fun ActiveProfilePicker(
if (activeProfile != null) {
val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId }
if (incognito) {
item {
IncognitoUserOption()
}
item {
ProfilePickerUserOption(activeProfile)
}
} else {
item {
ProfilePickerUserOption(activeProfile)
}
item {
IncognitoUserOption()
item {
when {
!showIncognito ->
ProfilePickerUserOption(activeProfile)
incognito -> {
IncognitoUserOption()
ProfilePickerUserOption(activeProfile)
}
else -> {
ProfilePickerUserOption(activeProfile)
IncognitoUserOption()
}
}
}
@@ -434,8 +421,10 @@ private fun ActiveProfilePicker(
ProfilePickerUserOption(p)
}
} else {
item {
IncognitoUserOption()
if (showIncognito) {
item {
IncognitoUserOption()
}
}
itemsIndexed(filteredProfiles) { _, p ->
ProfilePickerUserOption(p)
@@ -641,6 +630,18 @@ fun LinkTextView(link: String, share: Boolean) {
}
}
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return users.filter { u ->
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
true
} else {
correctPassword(u, s)
}
}
}
private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean {
if (text != null && strIsSimplexLink(text)) {
connect(rhId, text, close)
@@ -9,6 +9,7 @@ import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
@@ -606,6 +607,39 @@ object AppearanceScope {
}
}
@Composable
fun ColorModeSwitcher() {
val currentTheme by CurrentColors.collectAsState()
val themeMode = if (remember { appPrefs.currentTheme.state }.value == DefaultTheme.SYSTEM_THEME_NAME) {
if (systemInDarkThemeCurrently) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT
} else {
currentTheme.base.mode
}
val onLongClick = {
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
showToast(generalGetString(MR.strings.system_mode_toast))
saveThemeToDatabase(null)
}
Box(
modifier = Modifier
.clip(CircleShape)
.combinedClickable(
onClick = {
ThemeManager.applyTheme(if (themeMode == DefaultThemeMode.LIGHT) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName)
saveThemeToDatabase(null)
},
onLongClick = onLongClick
)
.onRightClick(onLongClick)
.size(44.dp),
contentAlignment = Alignment.Center
) {
Icon(painterResource(if (themeMode == DefaultThemeMode.LIGHT) MR.images.ic_light_mode else MR.images.ic_bedtime_moon), stringResource(MR.strings.color_mode_light), tint = MaterialTheme.colors.secondary)
}
}
private var updateBackendJob: Job = Job()
private fun saveThemeToDatabase(themeUserDestination: Pair<Long, ThemeModeOverrides?>?) {
val remoteHostId = chatModel.remoteHostId()
@@ -73,10 +73,7 @@ private fun SetDeliveryReceiptsLayout(
skip: () -> Unit,
userCount: Int,
) {
// This view located in the left panel which means it has to have a padding from right side in order
// to see scroll bar. And this padding should be applied to upper element, not scrollable column modifier
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
Box(Modifier.padding(top = DEFAULT_PADDING, end = endPadding)) {
Box(Modifier.padding(top = DEFAULT_PADDING)) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -31,13 +31,11 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.remote.ConnectDesktopView
import chat.simplex.common.views.remote.ConnectMobileView
import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
SettingsLayout(
@@ -71,10 +69,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt
}
},
withAuth = ::doWithAuth,
drawerState = drawerState,
)
KeyChangeEffect(chatModel.updatingProgress.value != null) {
drawerState.close()
close()
}
}
@@ -96,18 +93,11 @@ fun SettingsLayout(
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit,
drawerState: DrawerState,
) {
val scope = rememberCoroutineScope()
val closeSettings: () -> Unit = { scope.launch { drawerState.close() } }
val view = LocalMultiplatformView()
if (drawerState.isOpen) {
BackHandler {
closeSettings()
}
LaunchedEffect(Unit) {
hideKeyboard(view)
}
LaunchedEffect(Unit) {
hideKeyboard(view)
}
val theme = CurrentColors.collectAsState()
val uriHandler = LocalUriHandler.current
@@ -118,46 +108,22 @@ fun SettingsLayout(
) {
AppBarTitle(stringResource(MR.strings.your_settings))
SectionView(stringResource(MR.strings.settings_section_title_you)) {
val profileHidden = rememberSaveable { mutableStateOf(false) }
if (profile != null) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden, drawerState) } } }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped)
ChatPreferencesItem(showCustomModal, stopped = stopped)
} else if (chatModel.localUserCreated.value == false) {
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), {
withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
closeSettings()
}
CreateProfile(chatModel, close)
}
}
}, disabled = stopped)
}
if (appPlatform.isDesktop) {
SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped)
} else {
SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal { it, close -> ConnectDesktopView(close) }, disabled = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_chat_database)) {
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
@@ -535,7 +501,6 @@ fun PreviewSettingsLayout() {
showCustomModal = { {} },
showVersion = {},
withAuth = { _, _, _ -> },
drawerState = DrawerState(DrawerValue.Closed),
)
}
}
@@ -174,7 +174,7 @@ private fun UserAddressLayout(
saveAas: (AutoAcceptState, MutableState<AutoAcceptState>) -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId))
AppBarTitle(stringResource(MR.strings.public_address), hostDevice(user?.remoteHostId))
Column(
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -230,7 +230,7 @@ private fun UserAddressLayout(
private fun CreateAddressButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_qr_code),
stringResource(MR.strings.create_simplex_address),
stringResource(MR.strings.create_public_address),
onClick,
iconColor = MaterialTheme.colors.primary,
textColor = MaterialTheme.colors.primary,
@@ -34,6 +34,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
KeyChangeEffect(u.value?.remoteHostId, u.value?.userId) {
close()
}
if (user != null) {
var profile by remember { mutableStateOf(user.profile.toProfile()) }
UserProfileLayout(
@@ -36,11 +36,10 @@ import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
@Composable
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>, drawerState: DrawerState) {
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
val searchTextOrPassword = rememberSaveable { search }
val users by remember { derivedStateOf { m.users.map { it.user } } }
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
val scope = rememberCoroutineScope()
UserProfilesLayout(
users = users,
filteredUsers = filteredUsers,
@@ -51,12 +50,6 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
addUser = {
ModalManager.center.showModalCloseable { close ->
CreateProfile(m, close)
if (appPlatform.isDesktop) {
// Hide settings to allow clicks to pass through to CreateProfile view
DisposableEffectOnGone(always = { scope.launch { drawerState.close() } }) {
// Show settings again to allow intercept clicks to close modals after profile creation finishes
scope.launch(NonCancellable) { drawerState.open() } }
}
}
},
activateUser = { user ->
@@ -318,6 +318,7 @@
<string name="delete_message__question">Delete message?</string>
<string name="delete_messages__question">Delete %d messages?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="delete_messages_cannot_be_undone_warning">Messages will be deleted - this cannot be undone!</string>
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
<string name="delete_messages_mark_deleted_warning">Messages will be marked for deletion. The recipient(s) will be able to reveal these messages.</string>
<string name="delete_member_message__question">Delete member message?</string>
@@ -700,6 +701,10 @@
<string name="is_verified">%s is verified</string>
<string name="is_not_verified">%s is not verified</string>
<!-- user picker - UserPicker.kt -->
<string name="create_public_contact_address">Create public address</string>
<string name="your_public_contact_address">Your public address</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your SimpleX address</string>
@@ -848,6 +853,8 @@
<string name="shutdown_alert_desc">Notifications will stop working until you re-launch the app</string>
<!-- Address Items - UserAddressView.kt -->
<string name="public_address">Public address</string>
<string name="create_public_address">Create public address</string>
<string name="create_address">Create address</string>
<string name="delete_address__question">Delete address?</string>
<string name="your_contacts_will_remain_connected">Your contacts will remain connected.</string>
@@ -1149,6 +1156,7 @@
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
<string name="settings_section_title_settings">SETTINGS</string>
<string name="settings_section_title_chat_database">CHAT DATABASE</string>
<string name="settings_section_title_help">HELP</string>
<string name="settings_section_title_support">SUPPORT SIMPLEX CHAT</string>
<string name="settings_section_title_app">APP</string>
@@ -1713,6 +1721,7 @@
<string name="theme_remove_image">Remove image</string>
<string name="appearance_font_size">Font size</string>
<string name="appearance_zoom">Zoom</string>
<string name="system_mode_toast">System mode</string>
<!-- Wallpapers -->
<string name="wallpaper_preview_hello_alice">Good afternoon!</string>
@@ -2178,6 +2187,8 @@
<string name="migrate_from_device_creating_archive_link">Creating archive link</string>
<string name="migrate_from_device_cancel_migration">Cancel migration</string>
<string name="migrate_from_device_finalize_migration">Finalize migration</string>
<string name="migrate_from_device_remove_archive_question">Remove archive?</string>
<string name="migrate_from_device_uploaded_archive_will_be_removed">The uploaded database archive will be permanently removed from the servers.</string>
<string name="migrate_from_device_choose_migrate_from_another_device"><![CDATA[Choose <i>Migrate from another device</i> on the new device and scan QR code.]]></string>
<string name="migrate_from_device_or_share_this_file_link">Or securely share this file link</string>
<string name="migrate_from_device_delete_database_from_device">Delete database from this device</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M497-481q28-30.5 43.25-70.5T555.5-634q0-42.5-15-82.5T497-787q57.5 8 95.5 51.75t38 101.25q0 57.5-38 101.25T497-481Zm199 308q10-17 15.25-36t5.25-39.09v-38.27q0-34.45-15-65.54-15-31.1-40-55.1 48.23 17.4 89.36 44.95Q792-334.5 792-286.5v38.5q0 30.94-22.03 52.97Q747.94-173 717-173h-21Zm96-348.5h-41.5q-15.5 0-26.5-11T713-559q0-15.5 11-26.5t26.5-11H792V-638q0-15.5 11-26.5t26.5-11q15.5 0 26.5 11t11 26.5v41.5h41.5q15.5 0 26.5 11t11 26.5q0 15.5-11 26.5t-26.5 11H867v41.5q0 15.5-11 26.5t-26.5 11q-15.5 0-26.5-11T792-480v-41.5ZM325.5-479q-64.5 0-109.75-45.25T170.5-634q0-64.5 45.25-109.75T325.5-789q64.5 0 109.75 45.25T480.5-634q0 64.5-45.25 109.75T325.5-479Zm-311 231v-31.03q0-32.97 16.75-60.22t45.27-41.76Q137.5-411 199.75-426.25 262-441.5 325.5-441.5t125.75 15.25Q513.5-411 574.48-381.01q28.52 14.51 45.27 41.76Q636.5-312 636.5-279.03V-248q0 30.94-22.03 52.97Q592.44-173 561.5-173h-472q-30.94 0-52.97-22.03Q14.5-217.06 14.5-248Zm311-306q33 0 56.5-23.5t23.5-56.5q0-33-23.5-56.5T325.5-714q-33 0-56.5 23.5T245.5-634q0 33 23.5 56.5t56.5 23.5Zm-236 306h472v-31q0-11.19-5.5-20.34-5.5-9.16-15-14.16-53.5-26.5-107.17-39.75-53.68-13.25-108.33-13.25-55 0-108.5 13.25T110-313.5q-9.5 5-15 14.16-5.5 9.15-5.5 20.34v31Zm236-386Zm0 386Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M483.82-81.5q-83.12 0-156.43-31.82-73.32-31.81-127.85-86.25Q145-254 113.25-327.25 81.5-400.5 81.5-484q0-131.24 77-236.62t202.35-146.46q16.05-4.92 29.85 4.83t11.8 25.75q-6.5 88.5 24 170.4t94 145.35Q583.5-458 665.75-427t170.75 24q15-1.5 24.75 12.25t5.25 29.25q-40 125.5-145.35 202.75Q615.81-81.5 483.82-81.5ZM484-139q100.95 0 183.22-57.5Q749.5-254 800-343q-90.29-7.95-173.55-41.22Q543.2-417.5 479.5-481q-63.96-62.86-96.73-145.68Q350-709.5 342.5-798.5q-89 48.5-146.25 131.03Q139-584.95 139-484q0 144.38 100.31 244.69T484-139Zm-5-342Z"/></svg>

After

Width:  |  Height:  |  Size: 636 B

@@ -0,0 +1,86 @@
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.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.unit.dp
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(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
) {
if (users.isNotEmpty()) {
val userRows = users.chunked(5)
val rowsToDisplay = if (userRows.size > 2) 2 else userRows.size
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))
) {
ColumnWithScrollBar(
verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING)
) {
val spaceBetween = (((DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - (horizontalPadding)) - (55.dp * 5)) / 5
userRows.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spaceBetween),
) {
row.forEach { u ->
UserPickerInactiveUserBadge(u, stopped, size = 55.dp) {
onUserClicked(u.user)
}
}
}
}
}
}
}
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
stringResource(MR.strings.your_chat_profiles),
onShowAllProfilesClicked
)
}
@Composable
actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<AnimatedViewState>, content: @Composable () -> Unit) {
AnimatedVisibility(
visible = pickerState.value.isVisible(),
enter = fadeIn(),
exit = fadeOut()
) {
Box(
Modifier
.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING }),
contentAlignment = Alignment.TopStart
) {
ColumnWithScrollBar(modifier) {
content()
}
}
}
}
+2 -2
View File
@@ -23,7 +23,7 @@ import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Core
import Simplex.Chat.Options
import Simplex.Messaging.Transport.Server (runTCPServer)
import Simplex.Messaging.Transport.Server (runLocalTCPServer)
import Simplex.Messaging.Util (raceAny_)
import UnliftIO.Exception
import UnliftIO.STM
@@ -68,7 +68,7 @@ newChatServerClient qSize = do
runChatServer :: ChatServerConfig -> ChatController -> IO ()
runChatServer ChatServerConfig {chatPort, clientQSize} cc = do
started <- newEmptyTMVarIO
runTCPServer started chatPort $ \sock -> do
runLocalTCPServer started chatPort $ \sock -> do
ws <- liftIO $ getConnection sock
c <- atomically $ newChatServerClient clientQSize
putStrLn "client connected"
@@ -66,7 +66,7 @@ This service continues running when the app is switched off, and it is restarted
So, for Android we can now deliver instant message notifications without compromising users' privacy in any way. The app version 1.5 that includes private instant notifications is now available on [Play Store](https://play.google.com/store/apps/details?id=chat.simplex.app), in our [F-Droid repo](https://app.simplex.chat/) and via direct [APK](https://github.com/simplex-chat/simplex-chat/releases/latest/download/simplex.apk) downloads!
Please let us what needs to be improved - it's only the first version of instant notifications for Android!
Please let us know what needs to be improved - it's only the first version of instant notifications for Android!
## Our iOS approach has one trade-off
+1 -1
View File
@@ -12,7 +12,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: d559a66145cf7b4cd367c09974ed1ce8393940b2
tag: f5e666ae4f41351d5d5ac416cd6fb1d5fadc8ab7
source-repository-package
type: git
+2 -4
View File
@@ -1,14 +1,12 @@
---
title: Download SimpleX apps
permalink: /downloads/index.html
revision: 03.07.2024
revision: 09.09.2024
---
| Updated 03.07.2024 | Languages: EN |
| Updated 09.09.2024 | Languages: EN |
# Download SimpleX apps
The latest stable version is v5.8.
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
- [desktop](#desktop-app)
+1
View File
@@ -32,6 +32,7 @@ for ORIG_NAME in "${ORIG_NAMES[@]}"; do
(cd apk && zip -r -q -"$level" ../"$ORIG_NAME" .)
# Shouldn't be compressed because of Android requirement
(cd apk && zip -r -q -0 ../"$ORIG_NAME" resources.arsc)
(cd apk && zip -r -q -0 ../"$ORIG_NAME" lib/**/*.so)
if [ $case_insensitive -eq 1 ]; then
# For case-insensitive file systems
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."d559a66145cf7b4cd367c09974ed1ce8393940b2" = "1jav7jmriims6vlkxg8gmal03f9mbgrwc8v6g0rp95ivkx8gfjyw";
"https://github.com/simplex-chat/simplexmq.git"."f5e666ae4f41351d5d5ac416cd6fb1d5fadc8ab7" = "1cq9apm9vp40v4ck0wcbis4463q3cjd9fbx5511hhh6lah6llifc";
"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";
+157 -89
View File
@@ -55,9 +55,9 @@ import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time (NominalDiffTime, addUTCTime, defaultTimeLocale, formatTime)
import Data.Time.Clock (UTCTime, diffUTCTime, getCurrentTime, nominalDay, nominalDiffTimeToSeconds)
import Data.Time.Clock.System (systemToUTCTime)
import Data.Word (Word32)
import qualified Data.UUID as UUID
import qualified Data.UUID.V4 as V4
import Data.Word (Word32)
import qualified Database.SQLite.Simple as SQL
import Simplex.Chat.Archive
import Simplex.Chat.Call
@@ -115,7 +115,7 @@ import qualified Simplex.Messaging.Crypto.Ratchet as CR
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (base64P)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), EntityId, ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
import Simplex.Messaging.Protocol (AProtoServerWithAuth (..), AProtocolType (..), ErrorType (..), MsgBody, MsgFlags (..), NtfServer, ProtoServerWithAuth (..), ProtocolServer, ProtocolType (..), ProtocolTypeI (..), SProtocolType (..), SubscriptionMode (..), UserProtocol, XFTPServer, userProtocol)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.ServiceScheme (ServiceScheme (..))
import qualified Simplex.Messaging.TMap as TM
@@ -843,9 +843,7 @@ processChatCommand' vr = \case
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APIDeleteChatItem (ChatRef cType chatId) itemIds mode -> withUser $ \user -> case cType of
CTDirect -> withContactLock "deleteChatItem" chatId $ do
ct <- withStore $ \db -> getContact db vr user chatId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
(ct, items) <- getCommandDirectChatItems user chatId itemIds
case mode of
CIDMInternal -> deleteDirectCIs user ct items True False
CIDMBroadcast -> do
@@ -858,13 +856,9 @@ processChatCommand' vr = \case
if featureAllowed SCFFullDelete forUser ct
then deleteDirectCIs user ct items True False
else markDirectCIsDeleted user ct items True =<< liftIO getCurrentTime
where
getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user chatId itemId
CTGroup -> withGroupLock "deleteChatItem" chatId $ do
Group gInfo ms <- withStore $ \db -> getGroup db vr user chatId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
(gInfo, items) <- getCommandGroupChatItems user chatId itemIds
ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo
case mode of
CIDMInternal -> deleteGroupCIs user gInfo items True False Nothing =<< liftIO getCurrentTime
CIDMBroadcast -> do
@@ -874,17 +868,9 @@ processChatCommand' vr = \case
events = L.nonEmpty $ map (`XMsgDel` Nothing) msgIds
mapM_ (sendGroupMessages user gInfo ms) events
delGroupChatItems user gInfo items Nothing
where
getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user chatId itemId
CTLocal -> do
nf <- withStore $ \db -> getNoteFolder db user chatId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
(nf, items) <- getCommandLocalChatItems user chatId itemIds
deleteLocalCIs user nf items True False
where
getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user chatId itemId
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
where
@@ -902,9 +888,8 @@ processChatCommand' vr = \case
itemsMsgIds :: [CChatItem c] -> [SharedMsgId]
itemsMsgIds = mapMaybe (\(CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId}}) -> itemSharedMsgId)
APIDeleteMemberChatItem gId itemIds -> withUser $ \user -> withGroupLock "deleteChatItem" gId $ do
Group gInfo@GroupInfo {membership} ms <- withStore $ \db -> getGroup db vr user gId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db user) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
(gInfo@GroupInfo {membership}, items) <- getCommandGroupChatItems user gId itemIds
ms <- withFastStore' $ \db -> getGroupMembers db vr user gInfo
assertDeletable gInfo items
assertUserGroupRole gInfo GRAdmin
let msgMemIds = itemsMsgMemIds gInfo items
@@ -912,8 +897,6 @@ processChatCommand' vr = \case
mapM_ (sendGroupMessages user gInfo ms) events
delGroupChatItems user gInfo items (Just membership)
where
getGroupCI :: DB.Connection -> User -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
getGroupCI db user itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId
assertDeletable :: GroupInfo -> [CChatItem 'CTGroup] -> CM ()
assertDeletable GroupInfo {membership = GroupMember {memberRole = membershipMemRole}} items =
unless (all itemDeletable items) $ throwChatError CEInvalidChatItemDelete
@@ -980,6 +963,51 @@ processChatCommand' vr = \case
throwChatError (CECommandError $ "reaction already " <> if add then "added" else "removed")
when (add && length rs >= maxMsgReactions) $
throwChatError (CECommandError "too many reactions")
APIPlanForwardChatItems (ChatRef fromCType fromChatId) itemIds -> withUser $ \user -> case fromCType of
CTDirect -> planForward user . snd =<< getCommandDirectChatItems user fromChatId itemIds
CTGroup -> planForward user . snd =<< getCommandGroupChatItems user fromChatId itemIds
CTLocal -> planForward user . snd =<< getCommandLocalChatItems user fromChatId itemIds
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
where
planForward :: User -> [CChatItem c] -> CM ChatResponse
planForward user items = do
(itemIds', forwardErrors) <- unzip <$> mapM planItemForward items
let forwardConfirmation = case catMaybes forwardErrors of
[] -> Nothing
errs -> Just $ case mainErr of
FFENotAccepted _ -> FCFilesNotAccepted fileIds
FFEInProgress -> FCFilesInProgress filesCount
FFEMissing -> FCFilesMissing filesCount
FFEFailed -> FCFilesFailed filesCount
where
mainErr = minimum errs
fileIds = catMaybes $ map (\case FFENotAccepted ftId -> Just ftId; _ -> Nothing) errs
filesCount = length $ filter (mainErr ==) errs
pure CRForwardPlan {user, itemsCount = length itemIds, chatItemIds = catMaybes itemIds', forwardConfirmation}
where
planItemForward :: CChatItem c -> CM (Maybe ChatItemId, Maybe ForwardFileError)
planItemForward (CChatItem _ ci) = forwardMsgContent ci >>= maybe (pure (Nothing, Nothing)) (forwardContentPlan ci)
forwardContentPlan :: ChatItem c d -> MsgContent -> CM (Maybe ChatItemId, Maybe ForwardFileError)
forwardContentPlan ChatItem {file, meta = CIMeta {itemId}} mc = case file of
Nothing -> pure (Just itemId, Nothing)
Just CIFile {fileId, fileStatus, fileSource} -> case ciFileForwardError fileId fileStatus of
Just err -> pure $ itemIdWithoutFile err
Nothing -> case fileSource of
Just CryptoFile {filePath} -> do
exists <- doesFileExist . maybe filePath (</> filePath) =<< chatReadVar filesFolder
pure $ if exists then (Just itemId, Nothing) else itemIdWithoutFile FFEMissing
Nothing -> pure $ itemIdWithoutFile FFEMissing
where
itemIdWithoutFile err = (if hasContent then Just itemId else Nothing, Just err)
hasContent = case mc of
MCText _ -> True
MCLink {} -> True
MCImage {} -> True
MCVideo {text} -> text /= ""
MCVoice {text} -> text /= ""
MCFile t -> t /= ""
MCUnknown {} -> True
APIForwardChatItems (ChatRef toCType toChatId) (ChatRef fromCType fromChatId) itemIds itemTTL -> withUser $ \user -> case toCType of
CTDirect -> do
cmrs <- prepareForward user
@@ -987,96 +1015,76 @@ processChatCommand' vr = \case
Just cmrs' ->
withContactLock "forwardChatItem, to contact" toChatId $
sendContactContentMessages user toChatId False itemTTL cmrs'
Nothing -> throwChatError $ CEInternalError "no chat items to forward"
Nothing -> pure $ CRNewChatItems user []
CTGroup -> do
cmrs <- prepareForward user
case L.nonEmpty cmrs of
Just cmrs' ->
withGroupLock "forwardChatItem, to group" toChatId $
sendGroupContentMessages user toChatId False itemTTL cmrs'
Nothing -> throwChatError $ CEInternalError "no chat items to forward"
Nothing -> pure $ CRNewChatItems user []
CTLocal -> do
cmrs <- prepareForward user
case L.nonEmpty cmrs of
Just cmrs' ->
createNoteFolderContentItems user toChatId cmrs'
Nothing -> throwChatError $ CEInternalError "no chat items to forward"
Nothing -> pure $ CRNewChatItems user []
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
where
prepareForward :: User -> CM [ComposeMessageReq]
prepareForward user = case fromCType of
CTDirect -> withContactLock "forwardChatItem, from contact" fromChatId $ do
ct <- withFastStore $ \db -> getContact db vr user fromChatId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
mapM (ciComposeMsgReq ct) items
(ct, items) <- getCommandDirectChatItems user fromChatId itemIds
catMaybes <$> mapM (\ci -> ciComposeMsgReq ct ci <$$> prepareMsgReq ci) items
where
getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user fromChatId itemId
ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> CM ComposeMessageReq
ciComposeMsgReq ct (CChatItem _ ci) = do
(mc, mDir) <- forwardMC ci
file <- forwardCryptoFile ci
ciComposeMsgReq :: Contact -> CChatItem 'CTDirect -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
ciComposeMsgReq ct (CChatItem md ci) (mc', file) =
let itemId = chatItemId' ci
ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) mDir (Just fromChatId) (Just itemId))
pure (ComposedMessage file Nothing mc, ciff)
ciff = forwardCIFF ci $ Just (CIFFContact (forwardName ct) (toMsgDirection md) (Just fromChatId) (Just itemId))
in (ComposedMessage file Nothing mc', ciff)
where
forwardName :: Contact -> ContactName
forwardName Contact {profile = LocalProfile {displayName, localAlias}}
| localAlias /= "" = localAlias
| otherwise = displayName
CTGroup -> withGroupLock "forwardChatItem, from group" fromChatId $ do
gInfo <- withFastStore $ \db -> getGroupInfo db vr user fromChatId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
mapM (ciComposeMsgReq gInfo) items
(gInfo, items) <- getCommandGroupChatItems user fromChatId itemIds
catMaybes <$> mapM (\ci -> ciComposeMsgReq gInfo ci <$$> prepareMsgReq ci) items
where
getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user fromChatId itemId
ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> CM ComposeMessageReq
ciComposeMsgReq gInfo (CChatItem _ ci) = do
(mc, mDir) <- forwardMC ci
file <- forwardCryptoFile ci
ciComposeMsgReq :: GroupInfo -> CChatItem 'CTGroup -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
ciComposeMsgReq gInfo (CChatItem md ci) (mc', file) = do
let itemId = chatItemId' ci
ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) mDir (Just fromChatId) (Just itemId))
pure (ComposedMessage file Nothing mc, ciff)
ciff = forwardCIFF ci $ Just (CIFFGroup (forwardName gInfo) (toMsgDirection md) (Just fromChatId) (Just itemId))
in (ComposedMessage file Nothing mc', ciff)
where
forwardName :: GroupInfo -> ContactName
forwardName GroupInfo {groupProfile = GroupProfile {displayName}} = displayName
CTLocal -> do
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
mapM ciComposeMsgReq items
(_, items) <- getCommandLocalChatItems user fromChatId itemIds
catMaybes <$> mapM (\ci -> ciComposeMsgReq ci <$$> prepareMsgReq ci) items
where
getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user fromChatId itemId
ciComposeMsgReq :: CChatItem 'CTLocal -> CM ComposeMessageReq
ciComposeMsgReq (CChatItem _ ci) = do
(mc, _) <- forwardMC ci
file <- forwardCryptoFile ci
ciComposeMsgReq :: CChatItem 'CTLocal -> (MsgContent, Maybe CryptoFile) -> ComposeMessageReq
ciComposeMsgReq (CChatItem _ ci) (mc', file) =
let ciff = forwardCIFF ci Nothing
pure (ComposedMessage file Nothing mc, ciff)
in (ComposedMessage file Nothing mc', ciff)
CTContactRequest -> throwChatError $ CECommandError "not supported"
CTContactConnection -> throwChatError $ CECommandError "not supported"
where
forwardMC :: ChatItem c d -> CM (MsgContent, MsgDirection)
forwardMC ChatItem {meta = CIMeta {itemDeleted = Just _}} = throwChatError CEInvalidForward
forwardMC ChatItem {content = CISndMsgContent fmc} = pure (fmc, MDSnd)
forwardMC ChatItem {content = CIRcvMsgContent fmc} = pure (fmc, MDRcv)
forwardMC _ = throwChatError CEInvalidForward
prepareMsgReq :: CChatItem c -> CM (Maybe (MsgContent, Maybe CryptoFile))
prepareMsgReq (CChatItem _ ci) = forwardMsgContent ci $>>= forwardContent ci
forwardCIFF :: ChatItem c d -> Maybe CIForwardedFrom -> Maybe CIForwardedFrom
forwardCIFF ChatItem {meta = CIMeta {itemForwarded}} ciff = case itemForwarded of
Nothing -> ciff
Just CIFFUnknown -> ciff
Just prevCIFF -> Just prevCIFF
forwardCryptoFile :: ChatItem c d -> CM (Maybe CryptoFile)
forwardCryptoFile ChatItem {file = Nothing} = pure Nothing
forwardCryptoFile ChatItem {file = Just ciFile} = case ciFile of
forwardContent :: ChatItem c d -> MsgContent -> CM (Maybe (MsgContent, Maybe CryptoFile))
forwardContent ChatItem {file = Nothing} mc = pure $ Just (mc, Nothing)
forwardContent ChatItem {file = Just ciFile} mc = case ciFile of
CIFile {fileName, fileSource = Just fromCF@CryptoFile {filePath}} ->
chatReadVar filesFolder >>= \case
Nothing ->
ifM (doesFileExist filePath) (pure $ Just fromCF) (pure Nothing)
ifM (doesFileExist filePath) (pure $ Just (mc, Just fromCF)) (pure contentWithoutFile)
Just filesFolder -> do
let fsFromPath = filesFolder </> filePath
ifM
@@ -1089,10 +1097,17 @@ processChatCommand' vr = \case
let toCF = CryptoFile fsNewPath cfArgs
-- to keep forwarded file in case original is deleted
liftIOEither $ runExceptT $ withExceptT (ChatError . CEInternalError . show) $ copyCryptoFile (fromCF {filePath = fsFromPath} :: CryptoFile) toCF
pure $ Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile)
pure $ Just (mc, Just (toCF {filePath = takeFileName fsNewPath} :: CryptoFile))
)
(pure Nothing)
_ -> pure Nothing
(pure contentWithoutFile)
_ -> pure contentWithoutFile
where
contentWithoutFile = case mc of
MCImage {} -> Just (mc, Nothing)
MCLink {} -> Just (mc, Nothing)
_ | contentText /= "" -> Just (MCText contentText, Nothing)
_ -> Nothing
contentText = msgContentText mc
copyCryptoFile :: CryptoFile -> CryptoFile -> ExceptT CF.FTCryptoError IO ()
copyCryptoFile fromCF@CryptoFile {filePath = fsFromPath, cryptoArgs = fromArgs} toCF@CryptoFile {cryptoArgs = toArgs} = do
fromSizeFull <- getFileSize fsFromPath
@@ -1114,26 +1129,24 @@ processChatCommand' vr = \case
when (size' > 0) $ copyChunks r w size'
APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user
UserRead -> withUser $ \User {userId} -> processChatCommand $ APIUserRead userId
APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of
APIChatRead chatRef@(ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of
CTDirect -> do
user <- withFastStore $ \db -> getUserByContactId db chatId
timedItems <- withFastStore' $ \db -> getDirectUnreadTimedItems db user chatId fromToIds
ts <- liftIO getCurrentTime
forM_ timedItems $ \(itemId, ttl) -> do
let deleteAt = addUTCTime (realToFrac ttl) ts
withFastStore' $ \db -> setDirectChatItemDeleteAt db user chatId itemId deleteAt
startProximateTimedItemThread user (ChatRef CTDirect chatId, itemId) deleteAt
withFastStore' $ \db -> updateDirectChatItemsRead db user chatId fromToIds
timedItems <- withFastStore' $ \db -> do
timedItems <- getDirectUnreadTimedItems db user chatId fromToIds
updateDirectChatItemsRead db user chatId fromToIds
setDirectChatItemsDeleteAt db user chatId timedItems ts
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
CTGroup -> do
user@User {userId} <- withFastStore $ \db -> getUserByGroupId db chatId
timedItems <- withFastStore' $ \db -> getGroupUnreadTimedItems db user chatId fromToIds
user <- withFastStore $ \db -> getUserByGroupId db chatId
ts <- liftIO getCurrentTime
forM_ timedItems $ \(itemId, ttl) -> do
let deleteAt = addUTCTime (realToFrac ttl) ts
withFastStore' $ \db -> setGroupChatItemDeleteAt db user chatId itemId deleteAt
startProximateTimedItemThread user (ChatRef CTGroup chatId, itemId) deleteAt
withFastStore' $ \db -> updateGroupChatItemsRead db userId chatId fromToIds
timedItems <- withFastStore' $ \db -> do
timedItems <- getGroupUnreadTimedItems db user chatId fromToIds
updateGroupChatItemsRead db user chatId fromToIds
setGroupChatItemsDeleteAt db user chatId timedItems ts
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
CTLocal -> do
user <- withFastStore $ \db -> getUserByNoteFolderId db chatId
@@ -1141,6 +1154,24 @@ processChatCommand' vr = \case
ok user
CTContactRequest -> pure $ chatCmdError Nothing "not supported"
CTContactConnection -> pure $ chatCmdError Nothing "not supported"
APIChatItemsRead chatRef@(ChatRef cType chatId) itemIds -> withUser $ \_ -> case cType of
CTDirect -> do
user <- withFastStore $ \db -> getUserByContactId db chatId
timedItems <- withFastStore' $ \db -> do
timedItems <- updateDirectChatItemsReadList db user chatId itemIds
setDirectChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
CTGroup -> do
user <- withFastStore $ \db -> getUserByGroupId db chatId
timedItems <- withFastStore' $ \db -> do
timedItems <- updateGroupChatItemsReadList db user chatId itemIds
setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
ok user
CTLocal -> pure $ chatCmdError Nothing "not supported"
CTContactRequest -> pure $ chatCmdError Nothing "not supported"
CTContactConnection -> pure $ chatCmdError Nothing "not supported"
APIChatUnread (ChatRef cType chatId) unreadChat -> withUser $ \user -> case cType of
CTDirect -> do
withFastStore $ \db -> do
@@ -2791,7 +2822,10 @@ processChatCommand' vr = \case
filesInfo <- withFastStore' (`getUserFileInfo` user)
cancelFilesInProgress user filesInfo
deleteFilesLocally filesInfo
withAgent $ \a -> deleteUser a (aUserId user) delSMPQueues
withAgent (\a -> deleteUser a (aUserId user) delSMPQueues)
`catchChatError` \case
e@(ChatErrorAgent NO_USER _) -> toView $ CRChatError (Just user) e
e -> throwError e
withFastStore' (`deleteUserRecord` user)
when (activeUser user) $ chatWriteVar currentUser Nothing
ok_
@@ -3068,6 +3102,38 @@ processChatCommand' vr = \case
| (msg_, (ComposedMessage {msgContent}, itemForwarded), f, q) <-
zipWith4 (,,,) msgs_ (L.toList cmrs') (L.toList ciFiles_) (L.toList quotedItems_)
]
getCommandDirectChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (Contact, [CChatItem 'CTDirect])
getCommandDirectChatItems user ctId itemIds = do
ct <- withFastStore $ \db -> getContact db vr user ctId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getDirectCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
pure (ct, items)
where
getDirectCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTDirect))
getDirectCI db itemId = runExceptT . withExceptT ChatErrorStore $ getDirectChatItem db user ctId itemId
getCommandGroupChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (GroupInfo, [CChatItem 'CTGroup])
getCommandGroupChatItems user gId itemIds = do
gInfo <- withFastStore $ \db -> getGroupInfo db vr user gId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getGroupCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
pure (gInfo, items)
where
getGroupCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTGroup))
getGroupCI db itemId = runExceptT . withExceptT ChatErrorStore $ getGroupChatItem db user gId itemId
getCommandLocalChatItems :: User -> Int64 -> NonEmpty ChatItemId -> CM (NoteFolder, [CChatItem 'CTLocal])
getCommandLocalChatItems user nfId itemIds = do
nf <- withStore $ \db -> getNoteFolder db user nfId
(errs, items) <- lift $ partitionEithers <$> withStoreBatch (\db -> map (getLocalCI db) (L.toList itemIds))
unless (null errs) $ toView $ CRChatErrors (Just user) errs
pure (nf, items)
where
getLocalCI :: DB.Connection -> ChatItemId -> IO (Either ChatError (CChatItem 'CTLocal))
getLocalCI db itemId = runExceptT . withExceptT ChatErrorStore $ getLocalChatItem db user nfId itemId
forwardMsgContent :: ChatItem c d -> CM (Maybe MsgContent)
forwardMsgContent ChatItem {meta = CIMeta {itemDeleted = Just _}} = pure Nothing -- this can be deleted after selection
forwardMsgContent ChatItem {content = CISndMsgContent fmc} = pure $ Just fmc
forwardMsgContent ChatItem {content = CIRcvMsgContent fmc} = pure $ Just fmc
forwardMsgContent _ = throwChatError CEInvalidForward
createNoteFolderContentItems :: User -> NoteFolderId -> NonEmpty ComposeMessageReq -> CM ChatResponse
createNoteFolderContentItems user folderId cmrs = do
assertNoQuotes
@@ -7869,10 +7935,12 @@ chatCommandP =
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode),
"/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <*> _strP),
"/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP),
"/_forward plan " *> (APIPlanForwardChatItems <$> chatRefP <*> _strP),
"/_forward " *> (APIForwardChatItems <$> chatRefP <* A.space <*> chatRefP <*> _strP <*> sendMessageTTLP),
"/_read user " *> (APIUserRead <$> A.decimal),
"/read user" $> UserRead,
"/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))),
"/_read chat items " *> (APIChatItemsRead <$> chatRefP <*> _strP),
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
"/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode),
"/_clear chat " *> (APIClearChat <$> chatRefP),
+12
View File
@@ -298,10 +298,12 @@ data ChatCommand
| APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode
| APIDeleteMemberChatItem GroupId (NonEmpty ChatItemId)
| APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction}
| APIPlanForwardChatItems {fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId}
| APIForwardChatItems {toChatRef :: ChatRef, fromChatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId, ttl :: Maybe Int}
| APIUserRead UserId
| UserRead
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
| APIChatItemsRead ChatRef (NonEmpty ChatItemId)
| APIChatUnread ChatRef Bool
| APIDeleteChat ChatRef ChatDeleteMode -- currently delete mode settings are only applied to direct chats
| APIClearChat ChatRef
@@ -648,6 +650,7 @@ data ChatResponse
| CRContactRequestAlreadyAccepted {user :: User, contact :: Contact}
| CRLeftMemberUser {user :: User, groupInfo :: GroupInfo}
| CRGroupDeletedUser {user :: User, groupInfo :: GroupInfo}
| CRForwardPlan {user :: User, itemsCount :: Int, chatItemIds :: [ChatItemId], forwardConfirmation :: Maybe ForwardConfirmation}
| CRRcvFileDescrReady {user :: User, chatItem :: AChatItem, rcvFileTransfer :: RcvFileTransfer, rcvFileDescr :: RcvFileDescr}
| CRRcvFileAccepted {user :: User, chatItem :: AChatItem}
| CRRcvFileAcceptedSndCancelled {user :: User, rcvFileTransfer :: RcvFileTransfer}
@@ -904,6 +907,13 @@ connectionPlanProceed = \case
GLPConnectingConfirmReconnect -> True
_ -> False
data ForwardConfirmation
= FCFilesNotAccepted {fileIds :: [FileTransferId]}
| FCFilesInProgress {filesCount :: Int}
| FCFilesMissing {filesCount :: Int}
| FCFilesFailed {filesCount :: Int}
deriving (Show)
newtype UserPwd = UserPwd {unUserPwd :: Text}
deriving (Eq, Show)
@@ -1462,6 +1472,8 @@ $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "GLP") ''GroupLinkPlan)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CP") ''ConnectionPlan)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "FC") ''ForwardConfirmation)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "CE") ''ChatErrorType)
$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "RHE") ''RemoteHostError)
+21
View File
@@ -595,6 +595,27 @@ ciFileLoaded = \case
CIFSRcvWarning {} -> False
CIFSInvalid {} -> False
data ForwardFileError = FFENotAccepted FileTransferId | FFEInProgress | FFEFailed | FFEMissing
deriving (Eq, Ord)
ciFileForwardError :: FileTransferId -> CIFileStatus d -> Maybe ForwardFileError
ciFileForwardError fId = \case
CIFSSndStored -> Nothing
CIFSSndTransfer {} -> Nothing
CIFSSndComplete -> Nothing
CIFSSndCancelled -> Nothing
CIFSSndError {} -> Nothing
CIFSSndWarning {} -> Nothing
CIFSRcvInvitation -> Just $ FFENotAccepted fId
CIFSRcvAccepted -> Just FFEInProgress
CIFSRcvTransfer {} -> Just FFEInProgress
CIFSRcvAborted -> Just $ FFENotAccepted fId
CIFSRcvCancelled -> Just FFEFailed
CIFSRcvComplete -> Nothing
CIFSRcvError {} -> Just FFEFailed
CIFSRcvWarning {} -> Just FFEFailed
CIFSInvalid {} -> Just FFEFailed
data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d)
deriving instance Show ACIFileStatus
+37 -9
View File
@@ -60,10 +60,12 @@ module Simplex.Chat.Store.Messages
deleteLocalChatItem,
updateDirectChatItemsRead,
getDirectUnreadTimedItems,
setDirectChatItemDeleteAt,
updateDirectChatItemsReadList,
setDirectChatItemsDeleteAt,
updateGroupChatItemsRead,
getGroupUnreadTimedItems,
setGroupChatItemDeleteAt,
updateGroupChatItemsReadList,
setGroupChatItemsDeleteAt,
updateLocalChatItemsRead,
getChatRefViaItemId,
getChatItemVersions,
@@ -126,7 +128,9 @@ import Data.ByteString.Char8 (ByteString)
import Data.Either (fromRight, rights)
import Data.Int (Int64)
import Data.List (sortBy)
import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)
import Data.Ord (Down (..), comparing)
import Data.Text (Text)
import qualified Data.Text as T
@@ -1339,15 +1343,27 @@ getDirectUnreadTimedItems db User {userId} contactId itemsRange_ = case itemsRan
|]
(userId, contactId, CISRcvNew)
setDirectChatItemDeleteAt :: DB.Connection -> User -> ContactId -> ChatItemId -> UTCTime -> IO ()
setDirectChatItemDeleteAt db User {userId} contactId chatItemId deleteAt =
updateDirectChatItemsReadList :: DB.Connection -> User -> ContactId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)]
updateDirectChatItemsReadList db user contactId itemIds = do
catMaybes . L.toList <$> mapM getUpdateDirectItem itemIds
where
getUpdateDirectItem chatItemId = do
let itemsRange = Just (chatItemId, chatItemId)
timedItem <- maybeFirstRow id $ getDirectUnreadTimedItems db user contactId itemsRange
updateDirectChatItemsRead db user contactId itemsRange
pure timedItem
setDirectChatItemsDeleteAt :: DB.Connection -> User -> ContactId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)]
setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do
let deleteAt = addUTCTime (realToFrac ttl) currentTs
DB.execute
db
"UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND contact_id = ? AND chat_item_id = ?"
(deleteAt, userId, contactId, chatItemId)
pure (chatItemId, deleteAt)
updateGroupChatItemsRead :: DB.Connection -> UserId -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO ()
updateGroupChatItemsRead db userId groupId itemsRange_ = do
updateGroupChatItemsRead :: DB.Connection -> User -> GroupId -> Maybe (ChatItemId, ChatItemId) -> IO ()
updateGroupChatItemsRead db User {userId} groupId itemsRange_ = do
currentTs <- getCurrentTime
case itemsRange_ of
Just (fromItemId, toItemId) ->
@@ -1392,12 +1408,24 @@ getGroupUnreadTimedItems db User {userId} groupId itemsRange_ = case itemsRange_
|]
(userId, groupId, CISRcvNew)
setGroupChatItemDeleteAt :: DB.Connection -> User -> GroupId -> ChatItemId -> UTCTime -> IO ()
setGroupChatItemDeleteAt db User {userId} groupId chatItemId deleteAt =
updateGroupChatItemsReadList :: DB.Connection -> User -> GroupId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)]
updateGroupChatItemsReadList db user groupId itemIds = do
catMaybes . L.toList <$> mapM getUpdateGroupItem itemIds
where
getUpdateGroupItem chatItemId = do
let itemsRange = Just (chatItemId, chatItemId)
timedItem <- maybeFirstRow id $ getGroupUnreadTimedItems db user groupId itemsRange
updateGroupChatItemsRead db user groupId itemsRange
pure timedItem
setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)]
setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do
let deleteAt = addUTCTime (realToFrac ttl) currentTs
DB.execute
db
"UPDATE chat_items SET timed_delete_at = ? WHERE user_id = ? AND group_id = ? AND chat_item_id = ?"
(deleteAt, userId, groupId, chatItemId)
pure (chatItemId, deleteAt)
updateLocalChatItemsRead :: DB.Connection -> User -> NoteFolderId -> Maybe (ChatItemId, ChatItemId) -> IO ()
updateLocalChatItemsRead db User {userId} noteFolderId itemsRange_ = do
-1
View File
@@ -383,7 +383,6 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId =
|]
[":user_id" := userId, ":profile_id" := profileId]
type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, Bool, Maybe UIThemeEntityOverrides, Bool, Maybe CustomData)
type ContactRow = Only ContactId :. ContactRow'
+16 -1
View File
@@ -203,6 +203,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um]
CRUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m]
CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"]
CRForwardPlan u count itemIds fc -> ttyUser u $ viewForwardPlan count itemIds fc
CRRcvFileDescrReady _ _ _ _ -> []
CRRcvFileProgressXFTP {} -> []
CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci
@@ -930,6 +931,20 @@ viewUserContactLinkDeleted =
"To create a new chat address use " <> highlight' "/ad"
]
viewForwardPlan :: Int -> [ChatItemId] -> Maybe ForwardConfirmation -> [StyledString]
viewForwardPlan count itemIds = maybe [forwardCount] $ \fc -> [confirmation fc, forwardCount]
where
confirmation = \case
FCFilesNotAccepted fileIds -> plain $ "Files can be received: " <> intercalate ", " (map show fileIds)
FCFilesInProgress cnt -> plain $ "Still receiving " <> show cnt <> " file(s)"
FCFilesMissing cnt -> plain $ show cnt <> " file(s) are missing"
FCFilesFailed cnt -> plain $ "Receiving " <> show cnt <> " file(s) failed"
forwardCount
| count == len = "all messages can be forwarded"
| len == 0 = "nothing to forward"
| otherwise = plain $ show len <> " message(s) out of " <> show count <> " can be forwarded"
len = length itemIds
connReqContact_ :: StyledString -> ConnReqContact -> [StyledString]
connReqContact_ intro cReq =
[ intro,
@@ -2034,7 +2049,7 @@ viewChatError isCmd logLevel testView = \case
CEFallbackToSMPProhibited fileId -> ["recipient tried to accept file " <> sShow fileId <> " via old protocol, prohibited"]
CEInlineFileProhibited _ -> ["A small file sent without acceptance - you can enable receiving such files with -f option."]
CEInvalidQuote -> ["cannot reply to this message"]
CEInvalidForward -> ["cannot forward this message"]
CEInvalidForward -> ["cannot forward message(s)"]
CEInvalidChatItemUpdate -> ["cannot update this item"]
CEInvalidChatItemDelete -> ["cannot delete this item"]
CEHasCurrentCall -> ["call already in progress"]
+19
View File
@@ -3,6 +3,7 @@
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE PostfixOperators #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module ChatTests.Direct where
@@ -22,6 +23,7 @@ import Simplex.Chat.AppSettings (defaultAppSettings)
import qualified Simplex.Chat.AppSettings as AS
import Simplex.Chat.Call
import Simplex.Chat.Controller (ChatConfig (..))
import Simplex.Chat.Messages (ChatItemId)
import Simplex.Chat.Options (ChatOpts (..))
import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
@@ -38,6 +40,7 @@ chatDirectTests :: SpecWith FilePath
chatDirectTests = do
describe "direct messages" $ do
describe "add contact and send/receive messages" testAddContact
it "mark multiple messages as read" testMarkReadDirect
it "clear chat with contact" testContactClear
it "deleting contact deletes profile" testDeleteContactDeletesProfile
it "delete contact keeping conversation" testDeleteContactKeepConversation
@@ -212,6 +215,22 @@ testAddContact = versionTestMatrix2 runTestAddContact
then chatFeatures
else (0, e2eeInfoNoPQStr) : tail chatFeatures
testMarkReadDirect :: HasCallStack => FilePath -> IO ()
testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do
connectUsers alice bob
alice #> "@bob 1"
alice #> "@bob 2"
alice #> "@bob 3"
alice #> "@bob 4"
bob <# "alice> 1"
bob <# "alice> 2"
bob <# "alice> 3"
bob <# "alice> 4"
bob ##> "/last_item_id"
i :: ChatItemId <- read <$> getTermLine bob
let itemIds = intercalate "," $ map show [i - 3 .. i]
bob #$> ("/_read chat items @2 " <> itemIds, id, "ok")
testDuplicateContactsSeparate :: HasCallStack => FilePath -> IO ()
testDuplicateContactsSeparate =
testChat2 aliceProfile bobProfile $
+73 -18
View File
@@ -7,7 +7,11 @@ import ChatClient
import ChatTests.Utils
import Control.Concurrent (threadDelay)
import qualified Data.ByteString.Char8 as B
import System.Directory (copyFile, doesFileExist)
import Data.List (intercalate)
import qualified Data.Text as T
import System.Directory (copyFile, doesFileExist, removeFile)
import Simplex.Chat (fixedImagePreview)
import Simplex.Chat.Types (ImageData (..))
import Test.Hspec hiding (it)
chatForwardTests :: SpecWith FilePath
@@ -613,6 +617,8 @@ testForwardContactToContactMulti =
alice <# "bob> hey"
msgId2 <- lastItemId alice
alice ##> ("/_forward plan @2 " <> msgId1 <> "," <> msgId2)
alice <## "all messages can be forwarded"
alice ##> ("/_forward @3 @2 " <> msgId1 <> "," <> msgId2)
alice <# "@cath <- you @bob"
alice <## " hi"
@@ -642,6 +648,8 @@ testForwardGroupToGroupMulti =
alice <# "#team bob> hey"
msgId2 <- lastItemId alice
alice ##> ("/_forward plan #1 " <> msgId1 <> "," <> msgId2)
alice <## "all messages can be forwarded"
alice ##> ("/_forward #2 #1 " <> msgId1 <> "," <> msgId2)
alice <# "#club <- you #team"
alice <## " hi"
@@ -672,6 +680,7 @@ testMultiForwardFiles =
setRelativePaths alice "./tests/tmp/alice_app_files" "./tests/tmp/alice_xftp"
copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg"
copyFile "./tests/fixtures/test.pdf" "./tests/tmp/alice_app_files/test.pdf"
copyFile "./tests/fixtures/test_1MB.pdf" "./tests/tmp/alice_app_files/test_1MB.pdf"
setRelativePaths bob "./tests/tmp/bob_app_files" "./tests/tmp/bob_xftp"
setRelativePaths cath "./tests/tmp/cath_app_files" "./tests/tmp/cath_xftp"
connectUsers alice bob
@@ -686,32 +695,46 @@ testMultiForwardFiles =
-- send original files
let cm1 = "{\"msgContent\": {\"type\": \"text\", \"text\": \"message without file\"}}"
cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 1\"}}"
cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"text\", \"text\": \"sending file 2\"}}"
alice ##> ("/_send @2 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "]")
ImageData img = fixedImagePreview
cm2 = "{\"filePath\": \"test.jpg\", \"msgContent\": {\"type\": \"image\", \"image\":\"" <> T.unpack img <> "\", \"text\": \"\"}}"
cm3 = "{\"filePath\": \"test.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"\"}}"
cm4 = "{\"filePath\": \"test_1MB.pdf\", \"msgContent\": {\"type\": \"file\", \"text\": \"message with large file\"}}"
alice ##> ("/_send @2 json [" <> cm1 <> "," <> cm2 <> "," <> cm3 <> "," <> cm4 <> "]")
alice <# "@bob message without file"
alice <# "@bob sending file 1"
alice <# "/f @bob test.jpg"
alice <## "use /fc 1 to cancel sending"
alice <# "@bob sending file 2"
alice <# "/f @bob test.pdf"
alice <## "use /fc 2 to cancel sending"
alice <# "@bob message with large file"
alice <# "/f @bob test_1MB.pdf"
alice <## "use /fc 3 to cancel sending"
bob <# "alice> message without file"
bob <# "alice> sending file 1"
bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)"
bob <## "use /fr 1 [<dir>/ | <path>] to receive it"
bob <# "alice> sending file 2"
bob <# "alice> sends file test.pdf (266.0 KiB / 272376 bytes)"
bob <## "use /fr 2 [<dir>/ | <path>] to receive it"
bob <# "alice> message with large file"
bob <# "alice> sends file test_1MB.pdf (1017.7 KiB / 1042157 bytes)"
bob <## "use /fr 3 [<dir>/ | <path>] to receive it"
alice <## "completed uploading file 1 (test.jpg) for bob"
alice <## "completed uploading file 2 (test.pdf) for bob"
alice <## "completed uploading file 3 (test_1MB.pdf) for bob"
-- IDs to forward
let msgId1 = (read msgIdZero :: Int) + 1
msgIds = intercalate "," $ map show [msgId1, msgId1 + 1, msgId1 + 2, msgId1 + 3, msgId1 + 4]
bob ##> ("/_forward plan @2 " <> msgIds)
bob <## "Files can be received: 1, 2, 3"
bob <## "4 message(s) out of 5 can be forwarded"
bob ##> "/fr 1"
bob
@@ -720,6 +743,10 @@ testMultiForwardFiles =
]
bob <## "completed receiving file 1 (test.jpg) from alice"
bob ##> ("/_forward plan @2 " <> msgIds)
bob <## "Files can be received: 2, 3"
bob <## "4 message(s) out of 5 can be forwarded"
bob ##> "/fr 2"
bob
<### [ "saving file 2 from alice to test.pdf",
@@ -736,8 +763,10 @@ testMultiForwardFiles =
dest2 `shouldBe` src2
-- forward file
let msgId1 = (read msgIdZero :: Int) + 1
bob ##> ("/_forward @3 @2 " <> show msgId1 <> "," <> show (msgId1 + 1) <> "," <> show (msgId1 + 2) <> "," <> show (msgId1 + 3))
bob ##> ("/_forward plan @2 " <> msgIds)
bob <## "Files can be received: 3"
bob <## "all messages can be forwarded"
bob ##> ("/_forward @3 @2 " <> msgIds)
-- messages printed for bob
bob <# "@cath <- you @alice"
@@ -747,14 +776,17 @@ testMultiForwardFiles =
bob <## " message without file"
bob <# "@cath <- @alice"
bob <## " sending file 1"
bob <## " test_1.jpg"
bob <# "/f @cath test_1.jpg"
bob <## "use /fc 3 to cancel sending"
bob <## "use /fc 4 to cancel sending"
bob <# "@cath <- @alice"
bob <## " sending file 2"
bob <## " test_1.pdf"
bob <# "/f @cath test_1.pdf"
bob <## "use /fc 4 to cancel sending"
bob <## "use /fc 5 to cancel sending"
bob <# "@cath <- @alice"
bob <## " message with large file"
-- messages printed for cath
cath <# "bob> -> forwarded"
@@ -764,18 +796,21 @@ testMultiForwardFiles =
cath <## " message without file"
cath <# "bob> -> forwarded"
cath <## " sending file 1"
cath <## " test_1.jpg"
cath <# "bob> sends file test_1.jpg (136.5 KiB / 139737 bytes)"
cath <## "use /fr 1 [<dir>/ | <path>] to receive it"
cath <# "bob> -> forwarded"
cath <## " sending file 2"
cath <## " test_1.pdf"
cath <# "bob> sends file test_1.pdf (266.0 KiB / 272376 bytes)"
cath <## "use /fr 2 [<dir>/ | <path>] to receive it"
cath <# "bob> -> forwarded"
cath <## " message with large file"
-- file transfer
bob <## "completed uploading file 3 (test_1.jpg) for cath"
bob <## "completed uploading file 4 (test_1.pdf) for cath"
bob <## "completed uploading file 4 (test_1.jpg) for cath"
bob <## "completed uploading file 5 (test_1.pdf) for cath"
cath ##> "/fr 1"
cath
@@ -801,6 +836,26 @@ testMultiForwardFiles =
dest2C <- B.readFile "./tests/tmp/cath_app_files/test_1.pdf"
dest2C `shouldBe` src2B
bob ##> "/fr 3"
bob
<### [ "saving file 3 from alice to test_1MB.pdf",
"started receiving file 3 (test_1MB.pdf) from alice"
]
bob <## "completed receiving file 3 (test_1MB.pdf) from alice"
bob ##> ("/_forward plan @2 " <> msgIds)
bob <## "all messages can be forwarded"
removeFile "./tests/tmp/bob_app_files/test_1MB.pdf"
bob ##> ("/_forward plan @2 " <> msgIds)
bob <## "1 file(s) are missing"
bob <## "all messages can be forwarded"
removeFile "./tests/tmp/bob_app_files/test.pdf"
bob ##> ("/_forward plan @2 " <> msgIds)
bob <## "2 file(s) are missing"
bob <## "4 message(s) out of 5 can be forwarded"
-- deleting original file doesn't delete forwarded file
checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do
bob ##> "/clear alice"
+18
View File
@@ -16,6 +16,7 @@ import Data.List (intercalate, isInfixOf)
import qualified Data.Text as T
import Database.SQLite.Simple (Only (..))
import Simplex.Chat.Controller (ChatConfig (..))
import Simplex.Chat.Messages (ChatItemId)
import Simplex.Chat.Options
import Simplex.Chat.Protocol (supportedChatVRange)
import Simplex.Chat.Store (agentStoreFile, chatStoreFile)
@@ -34,6 +35,7 @@ chatGroupTests :: SpecWith FilePath
chatGroupTests = do
describe "chat groups" $ do
describe "add contacts, create group and send/receive messages" testGroupMatrix
it "mark multiple messages as read" testMarkReadGroup
it "v1: add contacts, create group and send/receive messages" testGroup
it "v1: add contacts, create group and send/receive messages, check messages" testGroupCheckMessages
it "send large message" testGroupLargeMessage
@@ -355,6 +357,22 @@ testGroupShared alice bob cath checkMessages directConnections = do
alice #$> ("/_unread chat #1 on", id, "ok")
alice #$> ("/_unread chat #1 off", id, "ok")
testMarkReadGroup :: HasCallStack => FilePath -> IO ()
testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do
createGroup2 "team" alice bob
alice #> "#team 1"
alice #> "#team 2"
alice #> "#team 3"
alice #> "#team 4"
bob <# "#team alice> 1"
bob <# "#team alice> 2"
bob <# "#team alice> 3"
bob <# "#team alice> 4"
bob ##> "/last_item_id"
i :: ChatItemId <- read <$> getTermLine bob
let itemIds = intercalate "," $ map show [i - 3 .. i]
bob #$> ("/_read chat items #1 " <> itemIds, id, "ok")
testGroupLargeMessage :: HasCallStack => FilePath -> IO ()
testGroupLargeMessage =
testChat2 aliceProfile bobProfile $