Compare commits

...

18 Commits

Author SHA1 Message Date
Evgeny Poberezkin aa9d9ad2a1 add animation, remove vstack 2024-07-01 11:39:07 +01:00
Evgeny Poberezkin 62cc26c873 Merge branch 'contacts-ui-feature-branch' of github.com:diogofcunha/simplex-chat into dc/contacts-search 2024-07-01 11:38:10 +01:00
Evgeny Poberezkin 02dc8a1597 Merge branch 'master' into contacts-ui-feature-branch 2024-07-01 11:30:47 +01:00
Evgeny Poberezkin 95c4e77075 Merge branch 'master' into contacts-ui-feature-branch 2024-06-30 23:42:48 +01:00
Diogo Cunha 9b6e0ad7fd feat: 🎸 made custom toolbar that merges search and nav
similar to ios safari, only available on ios 16+
2024-06-26 22:50:07 +01:00
Diogo Cunha 32946d4628 feat: 🎸 made only search bar dissapear on scroll 2024-06-20 17:39:10 +01:00
Diogo Cunha 0da98fbf60 chore: 🤖 reverted home view changes
BREAKING CHANGE: 🧨 g
2024-06-20 17:38:55 +01:00
Diogo 68606ee9ea Merge branch 'simplex-chat:contacts-ui-feature-branch' into contacts-ui-feature-branch 2024-06-13 22:33:28 +01:00
Diogo Cunha 8ed075f6db feat: 🎸 implemented dissapearing toolbar on chat list
toolbar dissapears when scrolling down and shows when scrolling back up
in normal UI. Also implemented for one hand ui
2024-06-13 22:32:07 +01:00
Evgeny Poberezkin 2a18770b96 Merge branch 'master' into contacts-ui-feature-branch 2024-06-11 11:18:39 +01:00
Evgeny Poberezkin dfdb3ca4a2 Merge branch 'master' into contacts-ui-feature-branch 2024-06-03 20:12:27 +01:00
spaced4ndy bd85b1063e multiplatform: contacts ui (#4214)
* types

* move stuff around

* move more stuff around

* padding

* tabs

* contacts list

* contact nav links, previews

* desktop list, better icon

* info

* refactor

* comments

* chat info buttons

* call buttons

* delete conversation dialogues

* change remember (contacts still don't update)

* contacts page refreshes on update

* delete contact dialogues

* move user picker to bottom left

* group member info buttons

* divider

* center on desktop

* comment

---------

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2024-06-03 14:13:22 +04:00
spaced4ndy 55c1200007 Merge branch 'master' into contacts-ui-feature-branch 2024-06-03 12:51:30 +04:00
spaced4ndy 7abcb6a449 Merge branch 'master' into contacts-ui-feature-branch 2024-05-20 18:59:56 +04:00
spaced4ndy a7cc8348fd ios: move Contacts views 2024-05-20 15:54:55 +04:00
spaced4ndy d1b44719f2 Merge branch 'master' into contacts-ui-feature-branch 2024-05-20 10:31:58 +04:00
spaced4ndy 995863d78b ios: contacts UI improvements 2024-05-20 10:22:03 +04:00
spaced4ndy 8f8601eaa4 ios: UI redesign, contacts (#4116)
* ios: chat delete mode api

* redesign wip

* wip

* filter button right of search

* rework navigation (mostly works?)

* remove modifier

* search in bottom bar

* make filter button easier to press

* increase button size

* customizable search position

* reverse chat list wip

* change material

* list going behind toolbars

* increase spacing

* rework wip

* rework, sheets

* more scale effect

* remove search buttons, rework filter button

* remove onboarding buttons

* scan/paste menu

* wip

* contacts wip

* sizes

* remove unnecessary modifier

* contacts navigation wip

* paddings

* rework chat info view approach

* comment

* comment

* verified marker

* comment

* fix list not updating

* delete contact/conversation

* delete via chat list (has bugs)

* comment

* swipe on contact list

* fixes

* buttons wip

* message button to open chat

* buttons disabled

* call buttons work from sheet

* call button from contacts

* fix buttons

* show keyboard attempts

* Revert "show keyboard attempts"

This reverts commit daa50d1aa9.

* comment

* mark contact chat as not deleted when opening from contacts

* move to old view

* dont reverse contacts in one-hand mode

* change icons

* simplify call buttons (revert to make calls from chat view)

* top bar, reduce padding

* increase filter button size

* support for contact cards

* fix some delete conversation bugs

* fix chat not being removed from list on deleting conversation

* add to app settings

* member view buttons

* icons

* remove unused code

* padding

* avatar

* resize avatar

* button

* add open button for deleted contact

* add deletedByUser status

* rework delete actions

* filter button in contacts list

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-05-13 17:04:46 +04:00
45 changed files with 2715 additions and 857 deletions
+1 -1
View File
@@ -230,7 +230,7 @@ struct ContentView: View {
private func mainView() -> some View {
ZStack(alignment: .top) {
ChatListView(showSettings: $showSettings).privacySensitive(protectScreen)
HomeView(showSettings: $showSettings).privacySensitive(protectScreen)
.onAppear {
requestNtfAuthorization()
// Local Authentication notice is to be shown on next start after onboarding is complete
+58 -4
View File
@@ -744,21 +744,37 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> (Co
return (nil, alert)
}
func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
let chatId = type.rawValue + id.description
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return }
throw r
}
func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
let type: ChatType = .direct
let chatId = type.rawValue + id.description
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
}
defer {
if case .full = chatDeleteMode {
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
}
}
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
if case let .contactDeleted(_, contact) = r { return contact }
throw r
}
func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
@@ -769,6 +785,39 @@ func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
}
}
func deleteChatContact(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async {
do {
let cInfo = chat.chatInfo
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
DispatchQueue.main.async {
switch chatDeleteMode {
case .full:
ChatModel.shared.removeChat(cInfo.id)
case .entity:
// removeChat forces list update (for ContactsView)
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: chat.chatItems
))
case .messages:
// removeChat forces list update (for ChatListView)
ChatModel.shared.removeChat(cInfo.id)
ChatModel.shared.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteChatContact apiDeleteContact error: \(responseError(error))")
AlertManager.shared.showAlertMsg(
title: "Error deleting chat!",
message: "Error: \(responseError(error))"
)
}
}
func apiClearChat(type: ChatType, id: Int64) async throws -> ChatInfo {
let r = await chatSendCmd(.apiClearChat(type: type, id: id), bgTask: false)
if case let .chatCleared(_, updatedChatInfo) = r { return updatedChatInfo }
@@ -1655,6 +1704,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
let cItem = aChatItem.chatItem
await MainActor.run {
if active(user) {
if case let .direct(contact) = cInfo, contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
m.updateContact(updatedContact)
}
m.addChatItem(cInfo, cItem)
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
+238 -32
View File
@@ -88,21 +88,36 @@ enum SendReceipts: Identifiable, Hashable {
}
}
enum ContactDeleteMode {
case full
case entity
public func toChatDeleteMode(notify: Bool) -> ChatDeleteMode {
switch self {
case .full: .full(notify: notify)
case .entity: .entity(notify: notify)
}
}
}
struct ChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var openedFromChatView: Bool
@ObservedObject var chat: Chat
@State var contact: Contact
@Binding var connectionStats: ConnectionStats?
@Binding var customUserProfile: Profile?
@State var localAlias: String
@Binding var connectionCode: String?
@State private var connectionStats: ConnectionStats? = nil
@State private var customUserProfile: Profile? = nil
@State private var connectionCode: String? = nil
@FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil
@State private var showDeleteContactActionSheet = false
@State private var actionSheet: ChatInfoViewActionSheet? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true
enum ChatInfoViewAlert: Identifiable {
case clearChatAlert
@@ -110,6 +125,7 @@ struct ChatInfoView: View {
case switchAddressAlert
case abortSwitchAddressAlert
case syncConnectionForceAlert
case deleteContactNotice
case queueInfo(info: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
@@ -120,12 +136,25 @@ struct ChatInfoView: View {
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case .deleteContactNotice: return "deleteContactNotice"
case let .queueInfo(info): return "queueInfo \(info)"
case let .error(title, _): return "error \(title)"
}
}
}
enum ChatInfoViewActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode)
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
NavigationView {
List {
@@ -142,6 +171,23 @@ struct ChatInfoView: View {
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
HStack {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
connectButton()
} else if !contact.active && !contact.chatDeleted {
openButton()
} else {
messageButton()
}
Spacer()
callButton()
Spacer()
videoButton()
}
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if let customUserProfile = customUserProfile {
Section("Incognito") {
HStack {
@@ -161,9 +207,9 @@ struct ChatInfoView: View {
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
@@ -249,6 +295,23 @@ struct ChatInfoView: View {
sendReceiptsUserDefault = currentUser.sendRcptsContacts
}
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
}
}
.alert(item: $alert) { alertItem in
switch(alertItem) {
@@ -257,28 +320,47 @@ struct ChatInfoView: View {
case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchContactAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncContactConnection(force: true) })
case .deleteContactNotice: return deleteContactNotice(contact)
case let .queueInfo(info): return queueInfoAlert(info)
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
.destructive(Text("Delete")) { deleteContact(notify: false) },
.cancel()
]
.actionSheet(item: $actionSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
var sheetButtons: [ActionSheet.Button] = []
sheetButtons.append(
.destructive(Text("Delete contact")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) }
)
} else {
if !contact.chatDeleted {
sheetButtons.append(
.destructive(Text("Delete contact, keep conversation")) { actionSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) }
)
}
sheetButtons.append(.cancel())
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact() },
.cancel()
]
title: Text("Delete contact?"),
buttons: sheetButtons
)
case let .confirmDeleteContactActionSheet(contactDeleteMode):
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true)) },
.destructive(Text("Delete without notification")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact(chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false)) },
.cancel()
]
)
}
}
}
}
@@ -349,6 +431,83 @@ struct ChatInfoView: View {
}
}
// when contact is a "contact card"
private func connectButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "connect")
.onTapGesture {
showConnectContactViaAddressDialog = true
}
.confirmationDialog("Connect with \(contact.chatViewName)", isPresented: $showConnectContactViaAddressDialog, titleVisibility: .visible) {
Button("Use current profile") { connectContactViaAddress_(contact, false) }
Button("Use new incognito profile") { connectContactViaAddress_(contact, true) }
}
}
private func connectContactViaAddress_(_ contact: Contact, _ incognito: Bool) {
Task {
let ok = await connectContactViaAddress(contact.contactId, incognito)
if ok {
await MainActor.run {
if openedFromChatView {
dismiss()
} else {
if contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
chatModel.updateContact(updatedContact)
}
chatModel.chatId = chat.id
}
}
}
}
}
private func openButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "open")
.onTapGesture {
if openedFromChatView {
dismiss()
} else {
chatModel.chatId = chat.id
}
}
}
// TODO show keyboard
private func messageButton() -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
if openedFromChatView {
dismiss()
} else {
if contact.chatDeleted {
var updatedContact = contact
updatedContact.chatDeleted = false
chatModel.updateContact(updatedContact)
}
chatModel.chatId = chat.id
}
}
.disabled(!contact.sendMsgEnabled)
}
private func callButton() -> some View {
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.onTapGesture {
CallController.shared.startCall(contact, .audio)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func videoButton() -> some View {
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.onTapGesture {
CallController.shared.startCall(contact, .video)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
VerifyCodeView(
@@ -452,9 +611,9 @@ struct ChatInfoView: View {
private func deleteContactButton() -> some View {
Button(role: .destructive) {
showDeleteContactActionSheet = true
actionSheet = .deleteContactActionSheet
} label: {
Label("Delete contact", systemImage: "trash")
Label("Delete contact", systemImage: "person.badge.minus")
.foregroundColor(Color.red)
}
}
@@ -468,17 +627,34 @@ struct ChatInfoView: View {
}
}
private func deleteContact(notify: Bool? = nil) {
private func deleteContact(chatDeleteMode: ChatDeleteMode) {
Task {
do {
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
let ct = try await apiDeleteContact(id: chat.chatInfo.apiId, chatDeleteMode: chatDeleteMode)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
switch chatDeleteMode {
case .full:
chatModel.removeChat(chat.chatInfo.id)
case .entity:
chatModel.updateContact(ct)
// dismissing sheet when opened from ChatView closes deleteContactNotice alert,
// also it makes less sense to show this alert as user is already in Chats tab
if showDeleteContactNotice && !openedFromChatView {
alert = .deleteContactNotice
}
case .messages:
logger.warning("ChatInfoView deleteContact case .messages should be unreachable")
chatModel.removeChat(chat.chatInfo.id)
chatModel.addChat(Chat(
chatInfo: .direct(contact: ct),
chatItems: []
))
}
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
logger.error("ChatInfoView deleteContact apiDeleteContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
@@ -563,6 +739,38 @@ struct ChatInfoView: View {
}
}
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
}
struct InfoViewActionButtonLayout: View {
var image: String
var title: LocalizedStringKey
var body: some View {
VStack(spacing: 4) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
.font(.caption)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.accentColor)
.background(Color(.secondarySystemGroupedBackground))
.cornerRadius(12.0)
.frame(width: 90, height: 60)
}
}
func switchAddressAlert(_ switchAddress: @escaping () -> Void) -> Alert {
@@ -611,12 +819,10 @@ func queueInfoAlert(_ info: String) -> Alert {
struct ChatInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatInfoView(
openedFromChatView: true,
chat: Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []),
contact: Contact.sampleData,
connectionStats: Binding.constant(nil),
customUserProfile: Binding.constant(nil),
localAlias: "",
connectionCode: Binding.constant(nil)
localAlias: ""
)
}
}
+9 -26
View File
@@ -23,9 +23,6 @@ struct ChatView: View {
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@State private var keyboardVisible = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
@State private var tableView: UITableView?
@State private var loadingItems = false
@State private var firstPage = false
@@ -45,6 +42,7 @@ struct ChatView: View {
var body: some View {
if #available(iOS 16.0, *) {
viewBody
.toolbarBackground(.visible, for: .navigationBar)
.scrollDismissesKeyboard(.immediately)
.keyboardPadding()
} else {
@@ -111,32 +109,17 @@ struct ChatView: View {
ToolbarItem(placement: .principal) {
if case let .direct(contact) = cInfo {
Button {
Task {
do {
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
let (ct, code) = try await apiGetContactCode(chat.chatInfo.apiId)
await MainActor.run {
connectionStats = stats
customUserProfile = profile
connectionCode = code
if contact.activeConn?.connectionCode != ct.activeConn?.connectionCode {
chat.chatInfo = .direct(contact: ct)
}
}
} catch let error {
logger.error("apiContactInfo or apiGetContactCode error: \(responseError(error))")
}
await MainActor.run { showChatInfoSheet = true }
}
showChatInfoSheet = true
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet, onDismiss: {
connectionStats = nil
customUserProfile = nil
connectionCode = nil
}) {
ChatInfoView(chat: chat, contact: contact, connectionStats: $connectionStats, customUserProfile: $customUserProfile, localAlias: chat.chatInfo.localAlias, connectionCode: $connectionCode)
.appSheet(isPresented: $showChatInfoSheet) {
ChatInfoView(
openedFromChatView: true,
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias
)
}
} else if case let .group(groupInfo) = cInfo {
Button {
@@ -64,10 +64,11 @@ struct GroupMemberInfoView: View {
}
}
private func knownDirectChat(_ contactId: Int64) -> Chat? {
private func knownDirectChat(_ contactId: Int64) -> (Chat, Contact)? {
if let chat = chatModel.getContactChat(contactId),
chat.chatInfo.contact?.directOrUsed == true {
return chat
let contact = chat.chatInfo.contact,
contact.directOrUsed == true {
return (chat, contact)
} else {
return nil
}
@@ -80,18 +81,35 @@ struct GroupMemberInfoView: View {
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
HStack {
if let contactId = member.memberContactId, let (chat, contact) = knownDirectChat(contactId) {
knownDirectChatButton(chat)
Spacer()
callButton(contact)
Spacer()
videoButton(contact)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
Spacer()
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.disabled(true)
Spacer()
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.disabled(true)
}
}
.padding(.horizontal)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
if member.memberActive {
Section {
if let contactId = member.memberContactId, let chat = knownDirectChat(contactId) {
knownDirectChatButton(chat)
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
createMemberContactButton()
}
}
if let code = connectionCode { verifyCodeButton(code) }
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
@@ -264,58 +282,71 @@ struct GroupMemberInfoView: View {
}
func knownDirectChatButton(_ chat: Chat) -> some View {
Button {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
Button {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
func newDirectChatButton(_ contactId: Int64) -> some View {
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
do {
let chat = try apiGetChat(type: .direct, id: contactId)
chatModel.addChat(chat)
dismissAllSheets(animated: true)
DispatchQueue.main.async {
chatModel.chatId = chat.id
}
} catch let error {
logger.error("openDirectChatButton apiGetChat error: \(responseError(error))")
}
}
}
func createMemberContactButton() -> some View {
Button {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
InfoViewActionButtonLayout(image: "message.fill", title: "message")
.onTapGesture {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
dismissAllSheets(animated: true)
chatModel.chatId = memberContact.id
chatModel.setContactNetworkStatus(memberContact, .connected)
}
} catch let error {
logger.error("createMemberContactButton apiCreateMemberContact error: \(responseError(error))")
let a = getErrorAlert(error, "Error creating member contact")
await MainActor.run {
progressIndicator = false
alert = .error(title: a.title, error: a.message)
}
}
}
}
} label: {
Label("Send direct message", systemImage: "message")
}
}
private func callButton(_ contact: Contact) -> some View {
InfoViewActionButtonLayout(image: "phone.fill", title: "call")
.onTapGesture {
CallController.shared.startCall(contact, .audio)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func videoButton(_ contact: Contact) -> some View {
InfoViewActionButtonLayout(image: "video.fill", title: "video")
.onTapGesture {
CallController.shared.startCall(contact, .video)
}
.disabled(!contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall != nil)
}
private func groupMemberInfoHeader(_ mem: GroupMember) -> some View {
@@ -63,7 +63,6 @@ struct ChatHelp: View {
struct ChatHelp_Previews: PreviewProvider {
static var previews: some View {
@State var showSettings = false
return ChatHelp(showSettings: $showSettings)
return ChatHelp(showSettings: Binding.constant(false))
}
}
@@ -32,11 +32,25 @@ struct ChatListNavLink: View {
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false
@State private var showDeleteContactActionSheet = false
@State private var contactNavLinkSheet: ContactNavLinkActionSheet? = nil
@State private var showConnectContactViaAddressDialog = false
@State private var inProgress = false
@State private var progressByTimeout = false
@AppStorage(DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE) private var showDeleteConversationNotice = true
enum ContactNavLinkActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
Group {
switch chat.chatInfo {
@@ -67,12 +81,12 @@ struct ChatListNavLink: View {
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
Group {
if contact.activeConn == nil && contact.profile.contactLink != nil {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showDeleteContactActionSheet = true
contactNavLinkSheet = .deleteContactActionSheet
} label: {
Label("Delete", systemImage: "trash")
}
@@ -100,7 +114,7 @@ struct ChatListNavLink: View {
}
Button {
if contact.ready || !contact.active {
showDeleteContactActionSheet = true
contactNavLinkSheet = .deleteContactActionSheet
} else {
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
}
@@ -112,24 +126,53 @@ struct ChatListNavLink: View {
.frame(height: rowHeights[dynamicTypeSize])
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
.actionSheet(item: $contactNavLinkSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
if contact.contactStatus == .deletedByUser {
return ActionSheet(
title: Text("Delete conversation?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete conversation")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Delete contact?"),
buttons: [
.destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet },
.destructive(Text("Only delete conversation")) {
Task {
await deleteChatContact(chat, chatDeleteMode: .messages)
if showDeleteConversationNotice {
AlertManager.shared.showAlert(deleteConversationNotice(contact))
}
}
},
.cancel()
]
)
}
case .confirmDeleteContactActionSheet:
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: true)) } },
.destructive(Text("Delete without notification")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChatContact(chat, chatDeleteMode: .full(notify: false)) } },
.cancel()
]
)
}
}
}
}
@@ -439,6 +482,17 @@ struct ChatListNavLink: View {
)
}
private func deleteConversationNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Conversation deleted!"),
message: Text("You can still send messages to \(contact.displayName) from the Contacts tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteConversationNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
private func deletePendingContactAlert(_ chat: Chat, _ contact: Contact) -> Alert {
Alert(
title: Text("Delete pending connection"),
+133 -202
View File
@@ -9,18 +9,19 @@
import SwiftUI
import SimpleXChat
struct ChatListView: View {
struct ChatListView<ToolbarContent: View>: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = ""
@State private var searchShowingSimplexLink = false
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@State var topVisibleRowIndex: Int? = nil;
@State private var searchVisible: Bool = true;
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
let toolbarContent: ToolbarContent?
var body: some View {
if #available(iOS 16.0, *) {
@@ -29,46 +30,16 @@ struct ChatListView: View {
viewBody
}
}
private var viewBody: some View {
ZStack(alignment: .topLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { _ in }
),
destination: chatView
) {
VStack {
if chatModel.chats.isEmpty {
onboardingButtons()
}
chatListView
}
}
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
}
}
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
}
init(@ViewBuilder toolbarContent: () -> ToolbarContent?) {
self.toolbarContent = toolbarContent()
}
private var chatListView: some View {
private var viewBody: some View {
VStack {
chatList
}
.onDisappear() { withAnimation { userPickerVisible = false } }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.refreshable {
AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"),
@@ -86,82 +57,34 @@ struct ChatListView: View {
))
}
.listStyle(.plain)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(searchMode)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 32, color: Color(uiColor: .quaternaryLabel))
.padding(.trailing, 4)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
unreadBadge(size: 12)
}
}
.onTapGesture {
if chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
}
}
ToolbarItem(placement: .principal) {
HStack(spacing: 4) {
Text("Chats")
.font(.headline)
if chatModel.chats.count > 0 {
toggleFilterButton()
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
ToolbarItem(placement: .navigationBarTrailing) {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton(newChatMenuOption: $newChatMenuOption)
case .some(false): chatStoppedIcon()
case .none: EmptyView()
}
}
}
}
private func toggleFilterButton() -> some View {
Button {
showUnreadAndFavorites = !showUnreadAndFavorites
} label: {
Image(systemName: "line.3.horizontal.decrease.circle" + (showUnreadAndFavorites ? ".fill" : ""))
.foregroundColor(.accentColor)
}
}
@ViewBuilder private var chatList: some View {
let cs = filteredChats()
ZStack {
ZStack(alignment: .top) {
VStack {
List {
if !chatModel.chats.isEmpty {
ChatListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
ScrollViewReader { scrollViewProxy in
List {
Color.clear
.frame(height: oneHandUI ? 80 : 30)
ForEach(cs.indices, id: \.self) { index in
ChatListNavLink(chat: cs[index])
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(cs[index].chatInfo.id))
.background(GeometryReader { proxy in
Color.clear
.onAppear {
updateTopVisibleRowIndex(proxy: proxy, index: index)
}
.onChange(of: proxy.frame(in: .named("SCROLL")).minY) { _ in
updateTopVisibleRowIndex(proxy: proxy, index: index)
}
})
}
.offset(x: -8)
}
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
}
.offset(x: -8)
.coordinateSpace(name: "SCROLL")
}
}
.onChange(of: chatModel.chatId) { _ in
@@ -171,7 +94,74 @@ struct ChatListView: View {
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats").foregroundColor(.secondary)
Text("No filtered chats")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
VStack {
if let tbcontent = toolbarContent, oneHandUI, #available(iOS 16.0, *), !searchFocussed {
tbcontent
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.frame(maxWidth: .infinity)
.padding(.horizontal, 25)
.padding(.bottom, 5)
.toolbar(.hidden, for: .bottomBar)
}
if !chatModel.chats.isEmpty && searchVisible {
ChatListSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
.padding(10)
.transition(.move(edge: .top))
}
}
.background(.bar)
}
}
private func updateTopVisibleRowIndex(proxy: GeometryProxy, index: Int) {
let frame = proxy.frame(in: .named("SCROLL"))
if (oneHandUI) {
let screenHeight = UIScreen.main.bounds.height
if frame.maxY <= screenHeight && frame.maxY > screenHeight - frame.height / 2 {
if topVisibleRowIndex != index {
withAnimation {
searchVisible = if let topVisibleRowIndex {
topVisibleRowIndex > index || index == 0
} else {
true
}
}
topVisibleRowIndex = index
}
}
} else {
if frame.minY >= 0 && frame.minY < frame.height / 2 {
if topVisibleRowIndex != index {
withAnimation {
searchVisible = if let topVisibleRowIndex {
topVisibleRowIndex > index
} else {
true
}
}
topVisibleRowIndex = index
}
}
}
}
@@ -182,66 +172,24 @@ struct ChatListView: View {
.foregroundColor(.accentColor)
}
private func onboardingButtons() -> some View {
VStack(alignment: .trailing, spacing: 0) {
Path { p in
p.move(to: CGPoint(x: 8, y: 0))
p.addLine(to: CGPoint(x: 16, y: 10))
p.addLine(to: CGPoint(x: 0, y: 10))
p.addLine(to: CGPoint(x: 8, y: 0))
}
.fill(Color.accentColor)
.frame(width: 20, height: 10)
.padding(.trailing, 12)
connectButton("Tap to start a new chat") {
newChatMenuOption = .newContact
}
Spacer()
Text("You have no chats")
.foregroundColor(.secondary)
.frame(maxWidth: .infinity)
}
.padding(.trailing, 6)
.frame(maxHeight: .infinity)
}
private func connectButton(_ label: LocalizedStringKey, action: @escaping () -> Void) -> some View {
Button(action: action) {
Text(label)
.padding(.vertical, 10)
.padding(.horizontal, 20)
}
.background(Color.accentColor)
.foregroundColor(.white)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
}
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
? chatModel.chats
? chatModel.chats.filter { chat in !chat.chatInfo.chatDeleted }
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
switch cInfo {
case let .direct(contact):
return s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
return !contact.chatDeleted && (
s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
)
case let .group(gInfo):
return s == ""
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
@@ -282,9 +230,9 @@ struct ChatListSearchBar: View {
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@State private var ignoreSearchTextChange = false
@State private var showScanCodeSheet = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
@@ -301,26 +249,6 @@ struct ChatListSearchBar: View {
.onTapGesture {
searchText = ""
}
} else if !searchFocussed {
HStack(spacing: 24) {
if m.pasteboardHasStrings {
Image(systemName: "doc")
.onTapGesture {
if let str = UIPasteboard.general.string {
searchText = str
}
}
}
Image(systemName: "qrcode")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.onTapGesture {
showScanCodeSheet = true
}
}
.padding(.trailing, 2)
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
@@ -335,13 +263,10 @@ struct ChatListSearchBar: View {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
Divider()
}
.sheet(isPresented: $showScanCodeSheet) {
NewChatView(selection: .connect, showQRCodeScanner: true)
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) // fixes .refreshable in ChatListView affecting nested view
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
@@ -376,6 +301,21 @@ struct ChatListSearchBar: View {
}
}
private func toggleFilterButton() -> some View {
ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? .accentColor : .secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
private func connect(_ link: String) {
planAndConnect(
link,
@@ -389,17 +329,6 @@ struct ChatListSearchBar: View {
}
}
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
title: "Chat is stopped",
message: "You can start chat via app Settings / Database or by restarting the app"
)
} label: {
Image(systemName: "exclamationmark.octagon.fill").foregroundColor(.red)
}
}
struct ChatListView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
@@ -419,10 +348,12 @@ struct ChatListView_Previews: PreviewProvider {
]
return Group {
ChatListView(showSettings: Binding.constant(false))
.environmentObject(chatModel)
ChatListView(showSettings: Binding.constant(false))
.environmentObject(ChatModel())
ChatListView {
EmptyView()
}.environmentObject(chatModel)
ChatListView {
EmptyView()
}.environmentObject(ChatModel())
}
}
}
@@ -204,7 +204,7 @@ struct ChatPreviewView: View {
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
chatPreviewInfoText("Tap to Connect")
.foregroundColor(.accentColor)
} else if !contact.ready && contact.activeConn != nil {
@@ -0,0 +1,210 @@
//
// ContactListNavLink.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 06.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactListNavLink: View {
@ObservedObject var chat: Chat
@State private var contactNavLinkSheet: ContactNavLinkActionSheet? = nil
@AppStorage(DEFAULT_SHOW_DELETE_CONTACT_NOTICE) private var showDeleteContactNotice = true
enum ContactNavLinkActionSheet: Identifiable {
case deleteContactActionSheet
case confirmDeleteContactActionSheet(contactDeleteMode: ContactDeleteMode)
var id: String {
switch self {
case .deleteContactActionSheet: return "deleteContactActionSheet"
case .confirmDeleteContactActionSheet: return "confirmDeleteContactActionSheet"
}
}
}
var body: some View {
switch chat.chatInfo {
case let .direct(contact):
NavigationLink {
ChatInfoView(
openedFromChatView: false,
chat: chat,
contact: contact,
localAlias: chat.chatInfo.localAlias
)
} label: {
HStack{
ZStack(alignment: .bottomTrailing) {
ProfileImage(imageStr: contact.image, size: 38)
chatPreviewImageOverlayIcon(contact)
.padding([.bottom, .trailing], 1)
}
.padding(.trailing, 2)
previewTitle(contact)
Spacer()
HStack {
if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.padding(.trailing, 1)
.foregroundColor(.secondary.opacity(0.65))
}
if contact.contactConnIncognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.foregroundColor(.secondary)
}
}
}
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
toggleFavoriteButton()
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
contactNavLinkSheet = .deleteContactActionSheet
} label: {
Label("Delete", systemImage: "person.badge.minus")
}
.tint(.red)
}
.actionSheet(item: $contactNavLinkSheet) { sheet in
switch(sheet) {
case .deleteContactActionSheet:
var sheetButtons: [ActionSheet.Button] = []
sheetButtons.append(
.destructive(Text("Delete contact")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .full) }
)
if !contact.chatDeleted {
sheetButtons.append(
.destructive(Text("Delete contact, keep conversation")) { contactNavLinkSheet = .confirmDeleteContactActionSheet(contactDeleteMode: .entity) }
)
}
sheetButtons.append(.cancel())
return ActionSheet(
title: Text("Delete contact?"),
buttons: sheetButtons
)
case let .confirmDeleteContactActionSheet(contactDeleteMode):
if contact.ready && contact.active {
return ActionSheet(
title: Text("Notify contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.destructive(Text("Delete without notification")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Confirm contact deletion.\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) {
Task {
await deleteChatContact(chat, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false))
if contactDeleteMode == .entity && showDeleteContactNotice {
AlertManager.shared.showAlert(deleteContactNotice(contact))
}
}
},
.cancel()
]
)
}
}
}
default:
EmptyView()
}
}
@ViewBuilder private func previewTitle(_ contact: Contact) -> some View {
let t = Text(chat.chatInfo.chatViewName)
(
contact.verified == true
? verifiedIcon + t
: t
)
.lineLimit(1)
}
private var verifiedIcon: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.foregroundColor(.secondary)
.baselineOffset(1)
.kerning(-2)
}
@ViewBuilder private func chatPreviewImageOverlayIcon(_ contact: Contact) -> some View {
if !contact.active {
inactiveIcon()
} else {
EmptyView()
}
}
private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
}
private func deleteContactNotice(_ contact: Contact) -> Alert {
return Alert(
title: Text("Contact deleted!"),
message: Text("You can still view conversation with \(contact.displayName) in the Chats tab."),
primaryButton: .default(Text("Don't show again")) {
showDeleteContactNotice = false
},
secondaryButton: .default(Text("Ok"))
)
}
@ViewBuilder private func toggleFavoriteButton() -> some View {
if chat.chatInfo.chatSettings?.favorite == true {
Button {
toggleChatFavorite(chat, favorite: false)
} label: {
Label("Unfav.", systemImage: "star.slash")
}
.tint(.green)
} else {
Button {
toggleChatFavorite(chat, favorite: true)
} label: {
Label("Favorite", systemImage: "star.fill")
}
.tint(.green)
}
}
}
#Preview {
ContactListNavLink(chat: Chat.sampleData)
}
@@ -0,0 +1,199 @@
//
// ContactsView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 06.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ContactsView: View {
@EnvironmentObject var chatModel: ChatModel
@State private var searchMode = false
@FocusState private var searchFocussed
@State private var searchText = ""
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
var body: some View {
if #available(iOS 16.0, *) {
viewBody.scrollDismissesKeyboard(.immediately)
} else {
viewBody
}
}
private var viewBody: some View {
VStack {
contactList
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listStyle(.plain)
}
@ViewBuilder private var contactList: some View {
let contactChats = contactChats()
let filteredContactChats = filteredContactChats(contactChats)
ZStack {
VStack {
List {
if !contactChats.isEmpty {
ContactsSearchBar(
searchMode: $searchMode,
searchFocussed: $searchFocussed,
searchText: $searchText
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
.frame(maxWidth: .infinity)
}
ForEach(filteredContactChats, id: \.viewId) { chat in
switch chat.chatInfo {
case let .direct(contact):
ContactListNavLink(chat: chat)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(contact.id))
default:
EmptyView()
}
}
}
}
if filteredContactChats.isEmpty && !contactChats.isEmpty {
Text("No filtered contacts")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
} else if contactChats.isEmpty {
Text("No contacts")
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
private func contactChats() -> [Chat] {
return chatModel.chats.filter { chat in
switch chat.chatInfo {
case .direct: true
default: false
}
}
}
private func filteredContactChats(_ contactChats: [Chat]) -> [Chat] {
let s = searchString()
return (
s == "" && !showUnreadAndFavorites
? contactChats.filter { chat in
switch chat.chatInfo {
case let .direct(contact): return contact.contactStatus != .deletedByUser
default: return false
}
}
: contactChats.filter { chat in
switch chat.chatInfo {
case let .direct(contact):
return contact.contactStatus != .deletedByUser && (
s == ""
? (chat.chatInfo.chatSettings?.favorite ?? false)
: (viewNameContains(contact, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
)
default: return false
}
}
)
.sorted{ $0.chatInfo.displayName.lowercased() < $1.chatInfo.displayName.lowercased() }
func searchString() -> String {
searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
func viewNameContains(_ contact: Contact, _ s: String) -> Bool {
contact.chatViewName.localizedLowercase.contains(s)
}
}
}
struct ContactsSearchBar: View {
@EnvironmentObject var m: ChatModel
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
VStack(spacing: 12) {
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
TextField("Search", text: $searchText)
.focused($searchFocussed)
.frame(maxWidth: .infinity)
if !searchText.isEmpty {
Image(systemName: "xmark.circle.fill")
.onTapGesture {
searchText = ""
}
}
}
.padding(EdgeInsets(top: 7, leading: 7, bottom: 7, trailing: 7))
.foregroundColor(.secondary)
.background(Color(.tertiarySystemFill))
.cornerRadius(10.0)
if searchFocussed {
Text("Cancel")
.foregroundColor(.accentColor)
.onTapGesture {
searchText = ""
searchFocussed = false
}
} else if m.chats.count > 0 {
toggleFilterButton()
}
}
}
.onChange(of: searchFocussed) { sf in
withAnimation { searchMode = sf }
}
}
private func toggleFilterButton() -> some View {
ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? .accentColor : .secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
}
}
}
}
struct ContactsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chats = [
Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: []
)
]
return Group {
ContactsView()
.environmentObject(chatModel)
ContactsView()
.environmentObject(ChatModel())
}
}
}
+238
View File
@@ -0,0 +1,238 @@
//
// HomeView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 01.05.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
enum HomeTab {
case contacts
case chats
}
struct HomeView: View {
@EnvironmentObject var chatModel: ChatModel
@Binding var showSettings: Bool
@State private var homeTab: HomeTab = .chats
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var toolbarHeight: CGFloat = 0
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
var body: some View {
ZStack(alignment: .bottomLeading) {
NavStackCompat(
isActive: Binding(
get: { chatModel.chatId != nil },
set: { _ in }
),
destination: chatView,
content: homeView
)
if userPickerVisible {
Rectangle().fill(.white.opacity(0.001)).onTapGesture {
withAnimation {
userPickerVisible.toggle()
}
}
}
UserPicker(
showSettings: $showSettings,
showConnectDesktop: $showConnectDesktop,
userPickerVisible: $userPickerVisible
)
}
.sheet(isPresented: $showConnectDesktop) {
ConnectDesktopView()
}
}
@ViewBuilder private func toolbar() -> some View {
settingsButton()
Spacer()
contactsButton()
Spacer()
chatsButton()
Spacer()
newChatButton()
}
@ViewBuilder private func homeView() -> some View {
let v = VStack {
switch homeTab {
case .contacts: withToolbar("Contacts", contactsView)
case .chats: withToolbar("Chats", chatListView)
}
}
.toolbar {
ToolbarItemGroup(placement: .bottomBar) {
toolbar()
}
}
if #unavailable(iOS 16) {
v
} else if oneHandUI {
v.toolbarBackground(.visible, for: .navigationBar, .bottomBar)
} else {
v.toolbarBackground(.visible, for: .bottomBar)
}
}
func withToolbar<V: View>(_ title: LocalizedStringKey, _ content: () -> V) -> some View {
content()
.navigationBarTitleDisplayMode(oneHandUI ? .inline : .large)
.navigationTitle(title)
}
@ViewBuilder private func settingsButton() -> some View {
let user = chatModel.currentUser ?? User.sampleData
let multiUser = chatModel.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1
Button {
if multiUser {
withAnimation {
userPickerVisible.toggle()
}
} else {
showSettings = true
}
} label: {
VStack(spacing: 2) {
ZStack(alignment: .topTrailing) {
ProfileImage(imageStr: user.image, size: 42, color: Color(uiColor: .quaternaryLabel))
.padding(.top, 3)
.padding(.trailing, 3)
let allRead = chatModel.users
.filter { u in !u.user.activeUser && !u.user.hidden }
.allSatisfy { u in u.unreadCount == 0 }
if !allRead {
userUnreadBadge(size: 12)
}
}
}
}
.foregroundColor(.secondary)
}
private func userUnreadBadge(_ text: Text? = Text(" "), size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(.accentColor)
}
private func contactsButton() -> some View {
Button {
homeTab = .contacts
} label: {
iconLabel(homeTab == .contacts ? "person.crop.circle.fill" : "person.crop.circle", "Contacts")
}
.foregroundColor(.secondary)
}
private func chatsButton() -> some View {
Button {
homeTab = .chats
} label: {
iconLabel(homeTab == .chats ? "message.fill" : "message", "Chats")
}
.foregroundColor(.secondary)
}
@ViewBuilder private func newChatButton() -> some View {
if case .some(false) = chatModel.chatRunning {
chatsStoppedButton()
} else {
Menu {
Button {
newChatMenuOption = .newGroup
} label: {
Text("Create group")
}
Button {
newChatMenuOption = .scanPaste
} label: {
Text("Scan / Paste link")
}
Button {
newChatMenuOption = .newContact
} label: {
Text("Add contact")
}
} label: {
iconLabel("square.and.pencil", "New chat")
}
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
case .newGroup: AddGroupView()
}
}
}
}
func chatsStoppedButton() -> some View {
Button {
AlertManager.shared.showAlertMsg(
title: "Chat is stopped",
message: "You can start chat via app Settings / Database or by restarting the app"
)
} label: {
VStack(spacing: 4) {
Image(systemName: "exclamationmark.octagon.fill")
.resizable()
.scaledToFit()
.foregroundColor(.red)
.frame(width: 24, height: 24)
Text("Stopped")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private func iconLabel(_ image: String, _ title: LocalizedStringKey) -> some View {
VStack(spacing: 2) {
Image(systemName: image)
.resizable()
.scaledToFit()
.frame(width: 24, height: 24)
Text(title)
.font(.caption2)
}
.padding(.top, 3)
}
@ViewBuilder private func contactsView() -> some View {
ContactsView()
}
@ViewBuilder private func chatListView() -> some View {
// TODO reverse scale effect for swipe actions
ChatListView {
HStack {
toolbar()
}
}
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat).onAppear {
loadChat(chat: chat)
}
}
}
}
#Preview {
HomeView(showSettings: Binding.constant(false))
}
@@ -10,6 +10,7 @@ import SwiftUI
enum NewChatMenuOption: Identifiable {
case newContact
case scanPaste
case newGroup
var id: Self { self }
@@ -25,6 +26,11 @@ struct NewChatMenuButton: View {
} label: {
Text("Add contact")
}
Button {
newChatMenuOption = .scanPaste
} label: {
Text("Scan / Paste link")
}
Button {
newChatMenuOption = .newGroup
} label: {
@@ -39,6 +45,7 @@ struct NewChatMenuButton: View {
.sheet(item: $newChatMenuOption) { opt in
switch opt {
case .newContact: NewChatView(selection: .invite)
case .scanPaste: NewChatView(selection: .connect, showQRCodeScanner: true)
case .newGroup: AddGroupView()
}
}
@@ -44,6 +44,7 @@ extension AppSettings {
if let val = androidCallOnLockScreen { def.setValue(val.rawValue, forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN) }
if let val = iosCallKitEnabled { callKitEnabledGroupDefault.set(val) }
if let val = iosCallKitCallsInRecents { def.setValue(val, forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS) }
if let val = oneHandUI { def.setValue(val, forKey: DEFAULT_ONE_HAND_UI) }
}
public static var current: AppSettings {
@@ -69,6 +70,7 @@ extension AppSettings {
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.oneHandUI = def.bool(forKey: DEFAULT_ONE_HAND_UI)
return c
}
}
@@ -25,6 +25,7 @@ struct AppearanceSettings: View {
@State private var userInterfaceStyle = getUserInterfaceStyleDefault()
@State private var uiTintColor = getUIAccentColorDefault()
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var profileImageCornerRadius = defaultProfileImageCorner
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
var body: some View {
VStack{
@@ -39,6 +40,12 @@ struct AppearanceSettings: View {
}
}
// Section("Interface") {
// settingsRow("hand.wave") {
// Toggle("One-hand UI", isOn: $oneHandUI)
// }
// }
Section("App icon") {
HStack {
updateAppIcon(image: "icon-light", icon: nil, tapped: $iconLightTapped)
@@ -12,6 +12,7 @@ import SimpleXChat
struct DeveloperView: View {
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(GROUP_DEFAULT_CONFIRM_DB_UPGRADES, store: groupDefaults) private var confirmDatabaseUpgrades = false
@AppStorage(DEFAULT_ONE_HAND_UI) private var oneHandUI = false
@Environment(\.colorScheme) var colorScheme
var body: some View {
@@ -31,9 +32,6 @@ struct DeveloperView: View {
} label: {
settingsRow("terminal") { Text("Chat console") }
}
settingsRow("internaldrive") {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
settingsRow("chevron.left.forwardslash.chevron.right") {
Toggle("Show developer options", isOn: $developerTools)
}
@@ -42,6 +40,19 @@ struct DeveloperView: View {
} footer: {
(developerTools ? Text("Show:") : Text("Hide:")) + Text(" ") + Text("Database IDs and Transport isolation option.")
}
if developerTools {
Section {
settingsRow("internaldrive") {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
settingsRow("hand.wave") {
Toggle("One-hand UI", isOn: $oneHandUI)
}
} header: {
Text("Developer options")
}
}
}
}
}
@@ -46,6 +46,7 @@ let DEFAULT_ACCENT_COLOR_GREEN = "accentColorGreen"
let DEFAULT_ACCENT_COLOR_BLUE = "accentColorBlue"
let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle"
let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius"
let DEFAULT_ONE_HAND_UI = "oneHandUI"
let DEFAULT_CONNECT_VIA_LINK_TAB = "connectViaLinkTab"
let DEFAULT_LIVE_MESSAGE_ALERT_SHOWN = "liveMessageAlertShown"
let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice"
@@ -60,6 +61,8 @@ let DEFAULT_DEVICE_NAME_FOR_REMOTE_ACCESS = "deviceNameForRemoteAccess"
let DEFAULT_CONFIRM_REMOTE_SESSIONS = "confirmRemoteSessions"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST = "connectRemoteViaMulticast"
let DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "connectRemoteViaMulticastAuto"
let DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
let DEFAULT_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
let DEFAULT_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
let ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN = "androidCallOnLockScreen"
@@ -90,6 +93,7 @@ let appDefaults: [String: Any] = [
DEFAULT_ACCENT_COLOR_BLUE: 1.000,
DEFAULT_USER_INTERFACE_STYLE: 0,
DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner,
DEFAULT_ONE_HAND_UI: false,
DEFAULT_CONNECT_VIA_LINK_TAB: ConnectViaLinkTab.scan.rawValue,
DEFAULT_LIVE_MESSAGE_ALERT_SHOWN: false,
DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true,
@@ -100,6 +104,8 @@ let appDefaults: [String: Any] = [
DEFAULT_CONFIRM_REMOTE_SESSIONS: false,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST: true,
DEFAULT_CONNECT_REMOTE_VIA_MULTICAST_AUTO: true,
DEFAULT_SHOW_DELETE_CONVERSATION_NOTICE: true,
DEFAULT_SHOW_DELETE_CONTACT_NOTICE: true,
DEFAULT_SHOW_SENT_VIA_RPOXY: false,
ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN: AppSettingsLockScreenCalls.show.rawValue
]
@@ -448,9 +454,8 @@ struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = User.sampleData
@State var showSettings = false
return SettingsView(showSettings: $showSettings)
return SettingsView(showSettings: Binding.constant(false))
.environmentObject(chatModel)
}
}
@@ -403,6 +403,6 @@ public func chatPasswordHash(_ pwd: String, _ salt: String) -> String {
struct UserProfilesView_Previews: PreviewProvider {
static var previews: some View {
UserProfilesView(showSettings: Binding.constant(true))
UserProfilesView(showSettings: Binding.constant(false))
}
}
@@ -150,6 +150,7 @@
6407BA83295DA85D0082BA18 /* CIInvalidJSONView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */; };
6419EC562AB8BC8B004A607A /* ContextInvitingContactMemberView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */; };
6419EC582AB97507004A607A /* CIMemberCreatedContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */; };
642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 642CE4942BE2651E00AD7757 /* HomeView.swift */; };
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */; };
6440CA00288857A10062C672 /* CIEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIEventView.swift */; };
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440CA02288AECA70062C672 /* AddGroupMembersView.swift */; };
@@ -167,9 +168,11 @@
646BB38E283FDB6D001CE359 /* LocalAuthenticationUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */; };
647F090E288EA27B00644C40 /* GroupMemberInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */; };
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6485F7C52BE8DB6500FAE413 /* ContactsView.swift */; };
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 */; };
64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A596442BE90DEC00B69266 /* ContactListNavLink.swift */; };
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 */; };
@@ -445,6 +448,7 @@
6407BA82295DA85D0082BA18 /* CIInvalidJSONView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIInvalidJSONView.swift; sourceTree = "<group>"; };
6419EC552AB8BC8B004A607A /* ContextInvitingContactMemberView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextInvitingContactMemberView.swift; sourceTree = "<group>"; };
6419EC572AB97507004A607A /* CIMemberCreatedContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMemberCreatedContactView.swift; sourceTree = "<group>"; };
642CE4942BE2651E00AD7757 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
6432857B2925443C00FBE5C8 /* GroupPreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GroupPreferencesView.swift; sourceTree = "<group>"; };
6440C9FF288857A10062C672 /* CIEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIEventView.swift; sourceTree = "<group>"; };
6440CA02288AECA70062C672 /* AddGroupMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupMembersView.swift; sourceTree = "<group>"; };
@@ -462,10 +466,12 @@
646BB38D283FDB6D001CE359 /* LocalAuthenticationUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalAuthenticationUtils.swift; sourceTree = "<group>"; };
647F090D288EA27B00644C40 /* GroupMemberInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupMemberInfoView.swift; sourceTree = "<group>"; };
648010AA281ADD15009009B9 /* CIFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIFileView.swift; sourceTree = "<group>"; };
6485F7C52BE8DB6500FAE413 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = "<group>"; };
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
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>"; };
64A596442BE90DEC00B69266 /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; 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>"; };
@@ -562,11 +568,13 @@
5C2E260D27A30E2400F70299 /* Views */ = {
isa = PBXGroup;
children = (
642CE4932BE2650500AD7757 /* Home */,
5CB0BA8C282711BC00B3292C /* Onboarding */,
3C714775281C080100CB4D4B /* Call */,
5C971E1F27AEBF7000C8A3CE /* Helpers */,
5C5F4AC227A5E9AF00B51EF1 /* Chat */,
5CB9250B27A942F300ACCCDD /* ChatList */,
6485F7C42BE8DB4F00FAE413 /* Contacts */,
5CB924DD27A8622200ACCCDD /* NewChat */,
5CFA59C22860B04D00863A68 /* Database */,
5CB634AB29E46CDB0066AD6B /* LocalAuth */,
@@ -899,6 +907,14 @@
path = Database;
sourceTree = "<group>";
};
642CE4932BE2650500AD7757 /* Home */ = {
isa = PBXGroup;
children = (
642CE4942BE2651E00AD7757 /* HomeView.swift */,
);
path = Home;
sourceTree = "<group>";
};
6440CA01288AEC770062C672 /* Group */ = {
isa = PBXGroup;
children = (
@@ -913,6 +929,15 @@
path = Group;
sourceTree = "<group>";
};
6485F7C42BE8DB4F00FAE413 /* Contacts */ = {
isa = PBXGroup;
children = (
6485F7C52BE8DB6500FAE413 /* ContactsView.swift */,
64A596442BE90DEC00B69266 /* ContactListNavLink.swift */,
);
path = Contacts;
sourceTree = "<group>";
};
8C7D94982B8894D300B7B9E1 /* Migration */ = {
isa = PBXGroup;
children = (
@@ -1148,6 +1173,7 @@
5CB0BA9A2827FD8800B3292C /* HowItWorks.swift in Sources */,
5C13730B28156D2700F43030 /* ContactConnectionView.swift in Sources */,
644EFFE0292CFD7F00525D5B /* CIVoiceView.swift in Sources */,
64A596452BE90DEC00B69266 /* ContactListNavLink.swift in Sources */,
6432857C2925443C00FBE5C8 /* GroupPreferencesView.swift in Sources */,
5C93293129239BED0090FFF9 /* ProtocolServerView.swift in Sources */,
5C9CC7AD28C55D7800BEF955 /* DatabaseEncryptionView.swift in Sources */,
@@ -1168,6 +1194,7 @@
5CB2084F28DA4B4800D024EC /* RTCServers.swift in Sources */,
5CB634AF29E4BB7D0066AD6B /* SetAppPasscodeView.swift in Sources */,
5C10D88828EED12E00E58BF0 /* ContactConnectionInfo.swift in Sources */,
6485F7C62BE8DB6500FAE413 /* ContactsView.swift in Sources */,
5CBE6C12294487F7002D9531 /* VerifyCodeView.swift in Sources */,
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */,
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
@@ -1212,6 +1239,7 @@
5CEBD7462A5C0A8F00665FE2 /* KeyboardPadding.swift in Sources */,
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
5CB634B129E5EFEA0066AD6B /* PasscodeView.swift in Sources */,
642CE4952BE2651E00AD7757 /* HomeView.swift in Sources */,
8C69FE7D2B8C7D2700267E38 /* AppSettings.swift in Sources */,
5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */,
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */,
+24 -11
View File
@@ -99,7 +99,7 @@ public enum ChatCommand {
case apiConnectPlan(userId: Int64, connReq: String)
case apiConnect(userId: Int64, incognito: Bool, connReq: String)
case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64)
case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?)
case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode)
case apiClearChat(type: ChatType, id: Int64)
case apiListContacts(userId: Int64)
case apiUpdateProfile(userId: Int64, profile: Profile)
@@ -257,11 +257,7 @@ public enum ChatCommand {
case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)"
case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)"
case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)"
case let .apiDeleteChat(type, id, notify): if let notify = notify {
return "/_delete \(ref(type, id)) notify=\(onOff(notify))"
} else {
return "/_delete \(ref(type, id))"
}
case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id)) \(chatDeleteMode.cmdString)"
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case let .apiListContacts(userId): return "/_contacts \(userId)"
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
@@ -485,10 +481,6 @@ public enum ChatCommand {
return nil
}
private func onOff(_ b: Bool) -> String {
b ? "on" : "off"
}
private func onOffParam(_ param: String, _ b: Bool?) -> String {
if let b = b {
return " \(param)=\(onOff(b))"
@@ -501,6 +493,10 @@ public enum ChatCommand {
}
}
private func onOff(_ b: Bool) -> String {
b ? "on" : "off"
}
public struct APIResponse: Decodable {
var resp: ChatResponse
}
@@ -1010,6 +1006,20 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
}
}
public enum ChatDeleteMode: Codable {
case full(notify: Bool)
case entity(notify: Bool)
case messages
var cmdString: String {
switch self {
case let .full(notify): "full notify=\(onOff(notify))"
case let .entity(notify): "entity notify=\(onOff(notify))"
case .messages: "messages"
}
}
}
public enum ConnectionPlan: Decodable {
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
case contactAddress(contactAddressPlan: ContactAddressPlan)
@@ -2071,6 +2081,7 @@ public struct AppSettings: Codable, Equatable {
public var androidCallOnLockScreen: AppSettingsLockScreenCalls? = nil
public var iosCallKitEnabled: Bool? = nil
public var iosCallKitCallsInRecents: Bool? = nil
public var oneHandUI: Bool? = nil
public func prepareForExport() -> AppSettings {
var empty = AppSettings()
@@ -2095,6 +2106,7 @@ public struct AppSettings: Codable, Equatable {
if androidCallOnLockScreen != def.androidCallOnLockScreen { empty.androidCallOnLockScreen = androidCallOnLockScreen }
if iosCallKitEnabled != def.iosCallKitEnabled { empty.iosCallKitEnabled = iosCallKitEnabled }
if iosCallKitCallsInRecents != def.iosCallKitCallsInRecents { empty.iosCallKitCallsInRecents = iosCallKitCallsInRecents }
if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI }
return empty
}
@@ -2119,7 +2131,8 @@ public struct AppSettings: Codable, Equatable {
confirmDBUpgrades: false,
androidCallOnLockScreen: AppSettingsLockScreenCalls.show,
iosCallKitEnabled: true,
iosCallKitCallsInRecents: false
iosCallKitCallsInRecents: false,
oneHandUI: false
)
}
}
+13 -1
View File
@@ -1287,6 +1287,15 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
}
}
public var chatDeleted: Bool {
get {
switch self {
case let .direct(contact): return contact.chatDeleted
default: return false
}
}
}
public var sendMsgEnabled: Bool {
get {
switch self {
@@ -1504,6 +1513,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
var chatTs: Date?
var contactGroupMemberId: Int64?
var contactGrpInvSent: Bool
public var chatDeleted: Bool
public var id: ChatId { get { "@\(contactId)" } }
public var apiId: Int64 { get { contactId } }
@@ -1570,13 +1580,15 @@ public struct Contact: Identifiable, Decodable, NamedChat {
mergedPreferences: ContactUserPreferences.sampleData,
createdAt: .now,
updatedAt: .now,
contactGrpInvSent: false
contactGrpInvSent: false,
chatDeleted: false
)
}
public enum ContactStatus: String, Decodable {
case active = "active"
case deleted = "deleted"
case deletedByUser = "deletedByUser"
}
public struct ContactRef: Decodable, Equatable {
@@ -1,4 +1,4 @@
package chat.simplex.common.views.chatlist
package chat.simplex.common.views.home
import android.app.Activity
import androidx.compose.foundation.*
@@ -4,16 +4,12 @@ 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
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
@@ -28,6 +24,7 @@ import chat.simplex.common.views.chat.ChatView
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.database.DatabaseErrorView
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.home.*
import chat.simplex.common.views.localauth.VerticalDivider
import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.usersettings.*
@@ -287,7 +284,7 @@ fun StartPartOfScreen(settingsState: SettingsViewState) {
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
HomeView(chatModel, settingsState, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, settingsState, stopped)
}
@@ -779,6 +779,7 @@ interface SomeChat {
val id: ChatId
val apiId: Long
val ready: Boolean
val chatDeleted: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
@@ -859,6 +860,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contact.id
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val chatDeleted get() = contact.chatDeleted
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val ntfsEnabled get() = contact.ntfsEnabled
override val incognito get() = contact.incognito
@@ -883,6 +885,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val chatDeleted get() = groupInfo.chatDeleted
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val ntfsEnabled get() = groupInfo.ntfsEnabled
override val incognito get() = groupInfo.incognito
@@ -907,6 +910,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = noteFolder.id
override val apiId get() = noteFolder.apiId
override val ready get() = noteFolder.ready
override val chatDeleted get() = noteFolder.chatDeleted
override val sendMsgEnabled get() = noteFolder.sendMsgEnabled
override val ntfsEnabled get() = noteFolder.ntfsEnabled
override val incognito get() = noteFolder.incognito
@@ -931,6 +935,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactRequest.id
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val chatDeleted get() = contactRequest.chatDeleted
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val ntfsEnabled get() = contactRequest.ntfsEnabled
override val incognito get() = contactRequest.incognito
@@ -955,6 +960,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactConnection.id
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val chatDeleted get() = contactConnection.chatDeleted
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = false
override val incognito get() = contactConnection.incognito
@@ -980,6 +986,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = ""
override val apiId get() = 0L
override val ready get() = false
override val chatDeleted get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
@@ -1056,6 +1063,7 @@ data class Contact(
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean,
override val chatDeleted: Boolean,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
@@ -1129,6 +1137,7 @@ data class Contact(
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false,
chatDeleted = false,
uiThemes = null,
)
}
@@ -1137,7 +1146,8 @@ data class Contact(
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
@SerialName("deleted") Deleted,
@SerialName("deletedByUser") DeletedByUser;
}
@Serializable
@@ -1276,6 +1286,7 @@ data class GroupInfo (
override val id get() = "#$groupId"
override val apiId get() = groupId
override val ready get() = membership.memberActive
override val chatDeleted get() = false
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = membership.memberIncognito
@@ -1581,6 +1592,7 @@ class NoteFolder(
override val chatType get() = ChatType.Local
override val id get() = "*$noteFolderId"
override val apiId get() = noteFolderId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = false
@@ -1617,6 +1629,7 @@ class UserContactRequest (
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
override val apiId get() = contactRequestId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -1656,6 +1669,7 @@ class PendingContactConnection(
override val chatType get() = ChatType.ContactConnection
override val id get () = ":$pccConnId"
override val apiId get() = pccConnId
override val chatDeleted get() = false
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -204,6 +204,8 @@ class AppPreferences {
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
val showDeleteConversationNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE, true)
val showDeleteContactNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE, true)
val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false)
@@ -380,6 +382,8 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
@@ -1108,16 +1112,16 @@ object ChatController {
}
}
suspend fun deleteChat(chat: Chat, notify: Boolean? = null) {
suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val cInfo = chat.chatInfo
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) {
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) {
chatModel.removeChat(chat.remoteHostId, cInfo.id)
}
}
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean {
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean {
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify))
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val success = when {
r is CR.ContactDeleted && type == ChatType.Direct -> true
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true
@@ -1138,6 +1142,22 @@ object ChatController {
return success
}
suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? {
val type = ChatType.Direct
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val contact = when {
r is CR.ContactDeleted -> r.contact
else -> {
val titleId = MR.strings.error_deleting_contact
apiErrorAlert("apiDeleteChat", generalGetString(titleId), r)
null
}
}
chatModel.deletedChats.value -= rh to type.type + id
return contact
}
fun clearChat(chat: Chat, close: (() -> Unit)? = null) {
withBGApi {
val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId)
@@ -1876,6 +1896,10 @@ object ChatController {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (active(r.user)) {
if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) {
val updatedContact = cInfo.contact.copy(chatDeleted = false)
chatModel.updateContact(rhId, updatedContact)
}
chatModel.addChatItem(rhId, cInfo, cItem)
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
@@ -2585,7 +2609,7 @@ sealed class CC {
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
@@ -2732,11 +2756,7 @@ sealed class CC {
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
is ApiDeleteChat -> if (notify != null) {
"/_delete ${chatRef(type, id)} notify=${onOff(notify)}"
} else {
"/_delete ${chatRef(type, id)}"
}
is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
@@ -2949,8 +2969,6 @@ sealed class CC {
null
}
private fun onOff(b: Boolean): String = if (b) "on" else "off"
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
companion object {
@@ -2960,6 +2978,8 @@ sealed class CC {
}
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
@Serializable
data class NewUser(
val profile: Profile?,
@@ -4781,6 +4801,19 @@ fun chatError(r: CR): ChatErrorType? {
)
}
@Serializable
sealed class ChatDeleteMode {
@Serializable @SerialName("full") class Full(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("entity") class Entity(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("messages") class Messages: ChatDeleteMode()
val cmdString: String get() = when (this) {
is ChatDeleteMode.Full -> "full notify=${onOff(notify)}"
is ChatDeleteMode.Entity -> "entity notify=${onOff(notify)}"
is ChatDeleteMode.Messages -> "messages"
}
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -12,12 +12,14 @@ import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
@@ -35,7 +37,8 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@@ -45,9 +48,21 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import java.io.File
sealed class ContactDeleteMode {
class Full: ContactDeleteMode()
class Entity: ContactDeleteMode()
fun toChatDeleteMode(notify: Boolean): ChatDeleteMode =
when (this) {
is Full -> ChatDeleteMode.Full(notify)
is Entity -> ChatDeleteMode.Entity(notify)
}
}
@Composable
fun ChatInfoView(
chatModel: ChatModel,
openedFromChatView: Boolean,
contact: Contact,
connectionStats: ConnectionStats?,
customUserProfile: Profile?,
@@ -68,6 +83,7 @@ fun ChatInfoView(
val chatRh = chat.remoteHostId
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
ChatInfoLayout(
openedFromChatView = openedFromChatView,
chat,
contact,
currentUser,
@@ -166,7 +182,8 @@ fun ChatInfoView(
)
}
}
}
},
close = close,
)
}
}
@@ -201,53 +218,127 @@ sealed class SendReceipts {
fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)),
buttons = {
Column {
if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) {
// Delete and notify contact
if (chatInfo is ChatInfo.Direct) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
buttons = {
Column {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = true)
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Full())
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete
if (!chatInfo.contact.chatDeleted) {
// Delete contact, keep conversation
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Entity())
}) {
Text(generalGetString(MR.strings.delete_contact_keep_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = false)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
fun notifyDeleteContactDialog(
chat: Chat,
chatModel: ChatModel,
close: (() -> Unit)? = null,
contactDeleteMode: ContactDeleteMode = ContactDeleteMode.Full()
) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactActive = chatInfo.contact.ready && chatInfo.contact.active
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactActive) generalGetString(MR.strings.notify_delete_contact_question) else generalGetString(MR.strings.confirm_delete_contact_question),
text = when (contactDeleteMode) {
is ContactDeleteMode.Full -> generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)
is ContactDeleteMode.Entity -> generalGetString(MR.strings.delete_contact_cannot_undo_warning)
},
buttons = {
Column {
if (contactActive) {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = true))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete without notification
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteContactNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.contact_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_view_conversation_with_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteContactNotice.set(false)
},
)
}
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val chatInfo = chat.chatInfo
withBGApi {
val chatRh = chat.remoteHostId
val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify)
if (r) {
chatModel.removeChat(chatRh, chatInfo.id)
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode)
if (ct != null) {
when (chatDeleteMode) {
is ChatDeleteMode.Full ->
chatModel.removeChat(chatRh, chatInfo.id)
is ChatDeleteMode.Entity ->
chatModel.updateContact(chatRh, ct)
is ChatDeleteMode.Messages ->
chatModel.clearChat(chatRh, ChatInfo.Direct(ct))
}
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
@@ -280,6 +371,7 @@ fun clearNoteFolderDialog(chat: Chat, close: (() -> Unit)? = null) {
@Composable
fun ChatInfoLayout(
openedFromChatView: Boolean,
chat: Chat,
contact: Contact,
currentUser: User,
@@ -300,6 +392,7 @@ fun ChatInfoLayout(
syncContactConnection: () -> Unit,
syncContactConnectionForce: () -> Unit,
verifyClicked: () -> Unit,
close: () -> Unit,
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
@@ -319,7 +412,31 @@ fun ChatInfoLayout(
}
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (contact.activeConn == null && contact.profile.contactLink != null && contact.active) {
ConnectButton(openedFromChatView, chat, contact, close)
} else if (!contact.active && !contact.chatDeleted) {
OpenButton(openedFromChatView, chat, contact, close)
} else {
MessageButton(openedFromChatView, chat, contact, close)
}
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
}
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
SectionItemViewSpaceBetween {
@@ -535,6 +652,155 @@ fun LocalAliasEditor(
}
}
// when contact is a "contact card"
@Composable
private fun ConnectButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_connect_button),
disabled = false,
onClick = {
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName),
buttons = {
Column {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = false, close)
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = true, close)
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
},
hostDevice = hostDevice(chat.remoteHostId),
)
}
)
}
private fun infoConnectContactViaAddress(openedFromChatView: Boolean, chat: Chat, contact: Contact, incognito: Boolean, close: () -> Unit) {
withBGApi {
val ok = connectContactViaAddress(chatModel, chat.remoteHostId, contact.contactId, incognito = incognito)
if (ok) {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
}
@Composable
private fun OpenButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_open_button),
disabled = false,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
private fun MessageButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = !contact.sendMsgEnabled,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
fun CallButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_call_filled),
title = generalGetString(MR.strings.info_view_call_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Audio)
}
)
}
@Composable
fun VideoButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_videocam_filled),
title = generalGetString(MR.strings.info_view_video_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Video)
}
)
}
@Composable
fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, onClick: () -> Unit) {
Surface(
Modifier
.width(96.dp)
.height(66.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colors.secondaryVariant,
) {
val modifier = if (disabled) Modifier else Modifier.clickable { onClick () }
Column(
modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = null,
Modifier.size(26.dp),
tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
Text(
title,
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal),
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
Row(
@@ -823,6 +1089,7 @@ fun queueInfoText(info: Pair<RcvMsgInfo?, QueueInfo>): String {
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
openedFromChatView = false,
chat = Chat(
remoteHostId = null,
chatInfo = ChatInfo.Direct.sampleData,
@@ -847,6 +1114,7 @@ fun PreviewChatInfoLayout() {
syncContactConnection = {},
syncContactConnectionForce = {},
verifyClicked = {},
close = {},
)
}
}
@@ -189,7 +189,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
ChatInfoView(chatModel, openedFromChatView = true, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
@@ -306,18 +306,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
onComplete.invoke()
}
},
startCall = out@{ media ->
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
},
startCall = out@{ media -> startChatCall(chat, media) },
endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
@@ -521,6 +510,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
fun startChatCall(chat: Chat, media: CallMediaType) {
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chat.remoteHostId, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
}
@Composable
fun ChatLayout(
chat: Chat,
@@ -36,6 +36,7 @@ import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chatlist.openLoadedChat
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -246,10 +247,10 @@ fun GroupMemberInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
fun knownDirectChat(contactId: Long): Chat? {
fun knownDirectChat(contactId: Long): Pair<Chat, Contact>? {
val chat = getContactChat(contactId)
return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
chat
chat to chat.chatInfo.contact
} else {
null
}
@@ -309,17 +310,37 @@ fun GroupMemberInfoLayout(
val contactId = member.memberContactId
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val knownChat = if (contactId != null) knownDirectChat(contactId) else null
if (knownChat != null) {
val (chat, contact) = knownChat
OpenChatButton(onClick = { openDirectChat(contact.contactId) })
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else {
OpenChatButton(onClick = { createMemberContact() })
}
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_call_filled), generalGetString(MR.strings.info_view_call_button), disabled = true, onClick = {})
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_videocam_filled), generalGetString(MR.strings.info_view_video_button), disabled = true, onClick = {})
}
}
SectionSpacer()
if (member.memberActive) {
SectionView {
if (contactId != null && knownDirectChat(contactId) != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) {
OpenChatButton(onClick = { createMemberContact() })
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
@@ -513,12 +534,11 @@ fun RemoveMemberButton(onClick: () -> Unit) {
@Composable
fun OpenChatButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_chat),
stringResource(MR.strings.button_send_direct_message),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = false,
onClick = onClick
)
}
@@ -172,7 +172,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
}
@Composable
private fun ErrorChatListItem() {
fun ErrorChatListItem() {
Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
@@ -407,13 +407,73 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
deleteContactConversationDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
fun deleteContactConversationDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactDeletedByUser = chatInfo.contact.contactStatus == ContactStatus.DeletedByUser
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_question) else generalGetString(MR.strings.delete_contact_question),
text = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_all_messages_deleted_cannot_undo_warning) else null,
buttons = {
Column {
if (contactDeletedByUser) {
// Delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Full(notify = false))
}) {
Text(generalGetString(MR.strings.delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Only delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages())
if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) {
showDeleteConversationNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteConversationNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.conversation_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_send_messages_to_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteConversationNotice.set(false)
},
)
}
@Composable
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -3,336 +3,46 @@ package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.TextRange
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.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import java.net.URI
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst()
}
AudioPlayer.stop()
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 = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(searchText, scaffoldState.drawerState, userPickerState, stopped)} },
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group))
}
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
}
if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
if (!stopped && !newChatSheetState.collectAsState().value.isVisible() && chatModel.chatRunning.value == true && searchText.value.text.isEmpty()) {
OnboardingButtons(showNewChatSheet)
}
}
}
}
}
if (searchText.value.text.isEmpty()) {
if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
}
}
// TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
}
}
}
@Composable
private fun OnboardingButtons(openNewChatSheet: () -> Unit) {
Column(Modifier.fillMaxSize().padding(DEFAULT_PADDING), horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.Bottom) {
ConnectButton(generalGetString(MR.strings.tap_to_start_new_chat), openNewChatSheet)
val color = MaterialTheme.colors.primaryVariant
Canvas(modifier = Modifier.width(40.dp).height(10.dp), onDraw = {
val trianglePath = Path().apply {
moveTo(0.dp.toPx(), 0f)
lineTo(16.dp.toPx(), 0.dp.toPx())
lineTo(8.dp.toPx(), 10.dp.toPx())
lineTo(0.dp.toPx(), 0.dp.toPx())
}
drawPath(
color = color,
path = trianglePath
)
})
Spacer(Modifier.height(62.dp))
}
}
@Composable
private fun ConnectButton(text: String, onClick: () -> Unit) {
Button(
onClick,
shape = RoundedCornerShape(21.dp),
colors = ButtonDefaults.textButtonColors(
backgroundColor = MaterialTheme.colors.primaryVariant
),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp)
) {
Text(text, color = Color.White)
}
}
@Composable
private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
val scope = rememberCoroutineScope()
DefaultTopAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} 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
}
}
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(MR.strings.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.chats.size > 0) {
val enabled = remember { derivedStateOf { searchInList.value.text.isEmpty() } }
if (enabled.value) {
ToggleFilterEnabledButton()
} else {
ToggleFilterDisabledButton()
}
}
}
},
onTitleClick = null,
showSearch = false,
onSearchValueChanged = {},
buttons = barButtons
)
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onButtonClicked) {
Box {
ProfileImage(
image = image,
size = 37.dp,
color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)
)
if (!allRead) {
unreadBadge()
}
}
}
if (appPlatform.isDesktop) {
val h by remember { chatModel.currentRemoteHost }
if (h != null) {
Spacer(Modifier.width(12.dp))
HostDisconnectButton {
stopRemoteHostAndReloadHosts(h!!, true)
}
}
}
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
text ?: "",
color = MaterialTheme.colors.onPrimary,
fontSize = 6.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.align(Alignment.TopEnd)
)
}
@Composable
private fun ToggleFilterEnabledButton() {
fun ToggleFilterButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
}
}
@Composable
private fun ToggleFilterDisabledButton() {
IconButton({}, enabled = false) {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.border(width = 1.dp, color = MaterialTheme.colors.secondary, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
}
}
@Composable
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = rhId to uri
} else {
withBGApi {
planAndConnect(rhId, uri, incognito = null, close = null)
}
}
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -359,28 +69,8 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
val clipboard = LocalClipboardManager.current
val clipboardHasText = remember(focused) { chatModel.clipboardHasText }.value
if (clipboardHasText) {
IconButton(
onClick = { searchText.value = searchText.value.copy(clipboard.getText()?.text ?: return@IconButton) },
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_article), null, tint = MaterialTheme.colors.secondary)
}
}
Spacer(Modifier.width(padding))
IconButton(
onClick = {
val fixedRhId = chatModel.currentRemoteHost.value
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close ->
NewChatView(fixedRhId, selection = NewChatOption.CONNECT, showQRCodeScanner = true, close = close)
}
},
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_qr_code), null, tint = MaterialTheme.colors.secondary)
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
Spacer(Modifier.width(padding))
}
@@ -437,17 +127,10 @@ private fun connect(link: String, searchChatFilteredBySimplexLink: MutableState<
}
}
@Composable
private fun ErrorSettingsView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
}
private var lazyListState = 0 to 0
@Composable
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
@@ -508,17 +191,18 @@ private fun filteredChats(
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty() && !showUnreadAndFavorites)
chats
chats.filter { chat -> !chat.chatInfo.chatDeleted }
else {
chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
}
is ChatInfo.Direct -> !chat.chatInfo.chatDeleted && (
if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
})
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
@@ -198,7 +198,7 @@ fun ChatPreviewView(
} else {
when (cInfo) {
is ChatInfo.Direct ->
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) {
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) {
Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary)
} else if (!cInfo.ready && cInfo.contact.activeConn != null) {
if (cInfo.contact.nextSendGrpInv) {
@@ -16,6 +16,7 @@ import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.home.UserProfileButton
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@@ -153,6 +153,8 @@ fun UserPicker(
) {
Column(
Modifier
.align(Alignment.BottomStart)
.padding(bottom = BottomAppBarHeight)
.widthIn(min = 260.dp)
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
@@ -0,0 +1,104 @@
package chat.simplex.common.views.contacts
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun ContactListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val disabled = chatModel.chatRunning.value == false || chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)
LaunchedEffect(chat.id) {
showMenu.value = false
delay(500L)
}
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == chatModel.chatId.value } }
val view = LocalMultiplatformView()
when (chat.chatInfo) {
is ChatInfo.Direct -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ContactListNavLink", error = { ErrorChatListItem() }) {
ContactPreviewView(chat, disabled)
}
},
click = {
val chatRh = chat.remoteHostId
if (ModalManager.end.hasModalsOpen()) {
ModalManager.end.closeModals()
// return@ChatLayout -- TODO what's this for?
}
hideKeyboard(view)
withBGApi {
var preloadedContactInfo: Pair<ConnectionStats?, Profile?>? = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
var preloadedCode: String? = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
ModalManager.end.showModalCloseable(true) { close ->
var contactInfo: Pair<ConnectionStats?, Profile?>? by remember { mutableStateOf(preloadedContactInfo) }
var code: String? by remember { mutableStateOf(preloadedCode) }
KeyChangeEffect(chat.id, ChatModel.networkStatuses.toMap()) {
contactInfo = chatModel.controller.apiContactInfo(chatRh, chat.chatInfo.apiId)
preloadedContactInfo = contactInfo
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, openedFromChatView = false, chat.chatInfo.contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
}
}
},
dropdownMenuItems = {
tryOrShowError("${chat.id}ContactListNavLinkDropdown", error = {}) {
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu)
}
},
showMenu,
disabled,
selectedChat,
nextChatSelected,
)
}
else -> {}
}
}
@Composable
fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
if (contact.activeConn != null) {
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
}
DeleteContactAction(chat, chatModel, showMenu)
}
@Composable
fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState<Boolean>) {
ItemAction(
if (favorite) stringResource(MR.strings.unfavorite_chat) else stringResource(MR.strings.favorite_chat),
if (favorite) painterResource(MR.images.ic_star_off) else painterResource(MR.images.ic_star),
onClick = {
toggleChatFavorite(chat, !favorite, chatModel)
showMenu.value = false
}
)
}
@Composable
fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
@@ -0,0 +1,117 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import chat.simplex.common.ui.theme.DEFAULT_SPACE_AFTER_ICON
import chat.simplex.res.MR
@Composable
fun ContactPreviewView(
chat: Chat,
disabled: Boolean,
) {
val cInfo = chat.chatInfo
@Composable
fun inactiveIcon() {
Icon(
painterResource(MR.images.ic_cancel_filled),
stringResource(MR.strings.icon_descr_group_inactive),
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
tint = MaterialTheme.colors.secondary
)
}
@Composable
fun chatPreviewImageOverlayIcon() {
when (cInfo) {
is ChatInfo.Direct ->
if (!cInfo.contact.active) {
inactiveIcon()
}
else -> {}
}
}
@Composable
fun VerifiedIcon() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
@Composable
fun chatPreviewTitle() {
val deleting by remember(disabled, chat.id) { mutableStateOf(chatModel.deletedChats.value.contains(chat.remoteHostId to chat.chatInfo.id)) }
when (cInfo) {
is ChatInfo.Direct ->
Row(verticalAlignment = Alignment.CenterVertically) {
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
cInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (deleting) MaterialTheme.colors.secondary else Color.Unspecified
)
}
else -> {}
}
}
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 42.dp)
Box(Modifier.padding(end = 2.dp, bottom = 2.dp)) {
chatPreviewImageOverlayIcon()
}
}
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Box(modifier = Modifier.weight(10f, fill = true)) {
chatPreviewTitle()
}
Spacer(Modifier.fillMaxWidth().weight(1f))
if (chat.chatInfo.chatSettings?.favorite == true) {
Icon(
painterResource(MR.images.ic_star_filled),
contentDescription = generalGetString(MR.strings.favorite_chat),
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(17.dp)
)
if (chat.chatInfo.incognito) {
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
}
}
if (chat.chatInfo.incognito) {
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
)
}
}
}
@@ -0,0 +1,160 @@
package chat.simplex.common.views.contacts
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.*
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.chatlist.ToggleFilterButton
import chat.simplex.common.views.home.contactChats
import chat.simplex.res.MR
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
private fun ContactsSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>) {
Row(verticalAlignment = Alignment.CenterVertically) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_verb),
alwaysVisible = true,
searchText = searchText,
trailingContent = null,
) {
searchText.value = searchText.value.copy(it)
}
val hasText = remember { derivedStateOf { searchText.value.text.isNotEmpty() } }
if (hasText.value) {
val hideSearchOnBack: () -> Unit = { searchText.value = TextFieldValue() }
BackHandler(onBack = hideSearchOnBack)
KeyChangeEffect(chatModel.currentRemoteHost.value) {
hideSearchOnBack()
}
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterButton()
}
Spacer(Modifier.width(padding))
}
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
LaunchedEffect(keyboardState.value) {
if (keyboardState.value == KeyboardState.Closed && focused) {
focusManager.clearFocus()
}
}
LaunchedEffect(Unit) {
snapshotFlow { searchText.value.text }
.distinctUntilChanged()
.collect {
if (it.isNotEmpty()) {
focusRequester.requestFocus()
} else if (listState.layoutInfo.totalItemsCount > 0) {
listState.scrollToItem(0)
}
}
}
}
}
private var lazyListState = 0 to 0
@Composable
fun ContactsList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
val allContactChats = remember(chatModel.chats.toList()) { contactChats(chatModel.chats) }
// See "IndexOutOfBoundsException" comment in ChatListView
val filteredContactChats = filteredContactChats(showUnreadAndFavorites, searchText.value.text, allContactChats.toList())
LazyColumnWithScrollBar(
Modifier.fillMaxWidth(),
listState
) {
stickyHeader {
Column(
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
} else {
0
}
IntOffset(0, y)
}
.background(MaterialTheme.colors.background)
) {
ContactsSearchBar(listState, searchText)
Divider()
}
}
itemsIndexed(filteredContactChats) { index, chat ->
val nextChatSelected = remember(chat.id, filteredContactChats) { derivedStateOf {
chatModel.chatId.value != null && filteredContactChats.getOrNull(index + 1)?.id == chatModel.chatId.value
} }
ContactListNavLinkView(chat, nextChatSelected)
}
}
if (filteredContactChats.isEmpty() && allContactChats.isNotEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_contacts), color = MaterialTheme.colors.secondary)
}
}
}
private fun filteredContactChats(
showUnreadAndFavorites: Boolean,
searchText: String,
contactChats: List<Chat>
): List<Chat> {
val s = searchText.trim().lowercase()
return (
if (s.isEmpty() && !showUnreadAndFavorites)
contactChats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser
else -> false
}
}
else {
contactChats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> cInfo.contact.contactStatus != ContactStatus.DeletedByUser && (
if (s.isEmpty()) {
chat.id == chatModel.chatId.value ||
(cInfo.chatSettings?.favorite ?: false)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
})
else -> false
}
}
}
).sortedBy { it.chatInfo.displayName.lowercase() }
}
private fun viewNameContains(cInfo: ChatInfo, s: String): Boolean =
cInfo.chatViewName.lowercase().contains(s.lowercase())
@@ -15,7 +15,7 @@ import chat.simplex.res.MR
@Composable
fun DefaultTopAppBar(
navigationButton: @Composable RowScope.() -> Unit,
navigationButton: (@Composable RowScope.() -> Unit)? = null,
title: (@Composable () -> Unit)?,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
@@ -125,5 +125,6 @@ private fun TopAppBar(
val AppBarHeight = 56.dp
val AppBarHorizontalPadding = 4.dp
val BottomAppBarHeight = 60.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
@@ -10,7 +10,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.ThemeOverrides
import chat.simplex.common.views.chatlist.connectIfOpenedViaUri
import chat.simplex.common.views.home.connectIfOpenedViaUri
import chat.simplex.res.MR
import com.charleskorn.kaml.decodeFromStream
import dev.icerock.moko.resources.StringResource
@@ -0,0 +1,372 @@
package chat.simplex.common.views.home
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.chatModel
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.onboarding.shouldShowWhatsNew
import chat.simplex.common.views.usersettings.SettingsView
import chat.simplex.common.platform.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.contacts.ContactsList
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import java.net.URI
sealed class HomeTab {
class Chats: HomeTab()
class Contacts: HomeTab()
}
@Composable
fun HomeView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val homeTab = remember { mutableStateOf<HomeTab>(HomeTab.Chats()) }
val newChatSheetState by rememberSaveable(stateSaver = AnimatedViewState.saver()) { mutableStateOf(MutableStateFlow(AnimatedViewState.GONE)) }
val showNewChatSheet = {
newChatSheetState.value = AnimatedViewState.VISIBLE
}
val hideNewChatSheet: (animated: Boolean) -> Unit = { animated ->
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close) }
}
}
LaunchedEffect(chatModel.clearOverlays.value) {
if (chatModel.clearOverlays.value && newChatSheetState.value.isVisible()) hideNewChatSheet(false)
}
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
ModalManager.end.closeModalsExceptFirst()
}
AudioPlayer.stop()
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 = { Box(Modifier.padding(end = endPadding)) { HomeTopBar(homeTab, stopped) } },
bottomBar = { Box(Modifier.padding(end = endPadding)) { HomeBottomBar(scaffoldState.drawerState, userPickerState, homeTab, stopped) } },
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
hoveredElevation = 0.dp,
focusedElevation = 0.dp,
),
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group))
}
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
when (homeTab.value) {
is HomeTab.Chats -> ChatsView(searchText)
is HomeTab.Contacts -> ContactsView(searchText)
}
}
}
if (searchText.value.text.isEmpty()) {
if (appPlatform.isDesktop) {
val call = remember { chatModel.activeCall }.value
if (call != null) {
ActiveCallInteractiveArea(call, newChatSheetState)
}
}
// TODO disable this button and sheet for the duration of the switch
tryOrShowError("NewChatSheet", error = {}) {
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
}
}
}
@Composable
private fun ChatsView(searchText: MutableState<TextFieldValue>) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
}
if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.you_have_no_chats),
Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary
)
}
}
}
@Composable
private fun ContactsView(searchText: MutableState<TextFieldValue>) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ContactsList(chatModel, searchText = searchText)
}
if (remember(chatModel.chats.toList()) { contactChats(chatModel.chats) }.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
if (chatModel.chatRunning.value == null) MR.strings.loading_chats else MR.strings.no_contacts),
Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary
)
}
}
}
fun contactChats(c: List<Chat>): List<Chat> {
return c.filter { it.chatInfo is ChatInfo.Direct }
}
@Composable
private fun HomeTopBar(homeTab: MutableState<HomeTab>, stopped: Boolean) {
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (stopped) {
barButtons.add {
IconButton(onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.chat_is_stopped_indication),
generalGetString(MR.strings.you_can_start_chat_via_setting_or_by_restarting_the_app)
)
}) {
Icon(
painterResource(MR.images.ic_report_filled),
generalGetString(MR.strings.chat_is_stopped_indication),
tint = Color.Red,
)
}
}
}
val title = when (homeTab.value) {
is HomeTab.Chats -> stringResource(MR.strings.your_chats)
is HomeTab.Contacts -> stringResource(MR.strings.contacts)
}
DefaultTopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
title,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
}
},
onTitleClick = null,
showSearch = false,
onSearchValueChanged = {},
buttons = barButtons
)
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
private fun HomeBottomBar(
drawerState: DrawerState,
userPickerState: MutableStateFlow<AnimatedViewState>,
homeTab: MutableState<HomeTab>,
stopped: Boolean) {
Box(
Modifier
.fillMaxWidth()
.height(BottomAppBarHeight)
.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f))
) {
Divider()
Row(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
SettingsButton(drawerState, userPickerState, stopped)
HomeTabButton(
icon = painterResource(if (homeTab.value is HomeTab.Chats) MR.images.ic_chat_bubble_filled else MR.images.ic_chat_bubble),
title = generalGetString(MR.strings.your_chats),
onClick = { homeTab.value = HomeTab.Chats() }
)
HomeTabButton(
icon = if (homeTab.value is HomeTab.Contacts) rememberVectorPainter(AccountCircleFilled) else painterResource(MR.images.ic_account_circle_filled),
title = generalGetString(MR.strings.contacts),
onClick = { homeTab.value = HomeTab.Contacts() }
)
}
}
}
@Composable
fun SettingsButton(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
val scope = rememberCoroutineScope()
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} 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
}
}
}
}
@Composable
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onButtonClicked) {
Box {
ProfileImage(
image = image,
size = 56.dp,
color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)
)
if (!allRead) {
unreadBadge()
}
}
}
if (appPlatform.isDesktop) {
val h by remember { chatModel.currentRemoteHost }
if (h != null) {
Spacer(Modifier.width(12.dp))
HostDisconnectButton {
stopRemoteHostAndReloadHosts(h!!, true)
}
}
}
}
}
@Composable
private fun BoxScope.unreadBadge(text: String? = "") {
Text(
text ?: "",
color = MaterialTheme.colors.onPrimary,
fontSize = 6.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.align(Alignment.TopEnd)
)
}
@Composable
fun HomeTabButton(icon: Painter, title: String, onClick: () -> Unit) {
Surface(
Modifier
.size(56.dp),
shape = RoundedCornerShape(10.dp),
color = Color.Transparent,
) {
Column(
Modifier
.clickable { onClick () },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = null,
Modifier.size(24.dp),
tint = MaterialTheme.colors.secondary
)
Text(
title,
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal, fontSize = 12.sp),
color = MaterialTheme.colors.secondary
)
}
}
}
@Composable
expect fun ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>)
fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = rhId to uri
} else {
withBGApi {
planAndConnect(rhId, uri, incognito = null, close = null)
}
}
}
@Composable
private fun ErrorSettingsView() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
}
@@ -44,6 +44,11 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
@@ -55,15 +60,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
private val titles = listOf(
MR.strings.add_contact_tab,
MR.strings.scan_paste_link,
MR.strings.create_group_button
)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_group)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group)
@Composable
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<AnimatedViewState>,
stopped: Boolean,
addContact: () -> Unit,
scanPaste: () -> Unit,
createGroup: () -> Unit,
closeNewChatSheet: (animated: Boolean) -> Unit,
) {
@@ -102,7 +109,7 @@ private fun NewChatSheetLayout(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
val actions = remember { listOf(addContact, createGroup) }
val actions = remember { listOf(addContact, scanPaste, createGroup) }
val backgroundColor = if (isInDarkTheme())
blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F)
else
@@ -145,7 +152,7 @@ private fun NewChatSheetLayout(
}
FloatingActionButton(
onClick = { if (!stopped) closeNewChatSheet(true) },
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + BottomAppBarHeight),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
@@ -264,6 +271,7 @@ private fun PreviewNewChatSheet() {
MutableStateFlow(AnimatedViewState.VISIBLE),
stopped = false,
addContact = {},
scanPaste = {},
createGroup = {},
closeNewChatSheet = {},
)
@@ -336,6 +336,7 @@
<string name="welcome">Welcome!</string>
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Chats</string>
<string name="contacts">Contacts</string>
<string name="contact_connection_pending">connecting…</string>
<string name="member_contact_send_direct_message">send direct message</string>
<string name="group_preview_you_are_invited">you are invited to group</string>
@@ -346,6 +347,8 @@
<string name="you_have_no_chats">You have no chats</string>
<string name="loading_chats">Loading chats…</string>
<string name="no_filtered_chats">No filtered chats</string>
<string name="no_contacts">No contacts</string>
<string name="no_filtered_contacts">No filtered contacts</string>
<string name="contact_tap_to_connect">Tap to Connect</string>
<string name="connect_with_contact_name_question">Connect with %1$s?</string>
<string name="search_or_paste_simplex_link">Search or paste SimpleX link</string>
@@ -427,10 +430,28 @@
<string name="notifications">Notifications</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="info_view_connect_button">connect</string>
<string name="info_view_open_button">open</string>
<string name="info_view_message_button">message</string>
<string name="info_view_call_button">call</string>
<string name="info_view_video_button">video</string>
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="delete_contact_cannot_undo_warning">Contact will be deleted - this cannot be undone!</string>
<string name="delete_conversation_question">Delete conversation?</string>
<string name="delete_conversation_all_messages_deleted_cannot_undo_warning">Conversation and all messages will be deleted - this cannot be undone!</string>
<string name="delete_conversation">Delete conversation</string>
<string name="only_delete_conversation">Only delete conversation</string>
<string name="delete_contact_keep_conversation">Delete contact, keep conversation</string>
<string name="notify_delete_contact_question">Notify contact?</string>
<string name="confirm_delete_contact_question">Confirm contact deletion?</string>
<string name="delete_and_notify_contact">Delete and notify contact</string>
<string name="delete_without_notification">Delete without notification</string>
<string name="button_delete_contact">Delete contact</string>
<string name="conversation_deleted">Conversation deleted!</string>
<string name="you_can_still_send_messages_to_contact">You can still send messages to %1$s from the Contacts tab.</string>
<string name="contact_deleted">Contact deleted!</string>
<string name="you_can_still_view_conversation_with_contact">You can still view conversation with %1$s in the Chats tab.</string>
<string name="text_field_set_contact_placeholder">Set contact name…</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -617,6 +638,7 @@
<!-- NewChatView.kt -->
<string name="new_chat">New chat</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M241.78-244.5 134-136.5q-13.5 13.5-31.25 6.52Q85-136.97 85-156.5V-818q0-22.97 17.27-40.23 17.26-17.27 40.23-17.27h675q22.97 0 40.23 17.27Q875-840.97 875-818v516q0 22.97-17.27 40.23-17.26 17.27-40.23 17.27H241.78Z"/></svg>

After

Width:  |  Height:  |  Size: 337 B

@@ -1,74 +0,0 @@
package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction
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 ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (!newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) {
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
}
}
}
}
}
@@ -0,0 +1,76 @@
package chat.simplex.common.views.home
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.Call
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.NoIndication
import chat.simplex.common.views.chatlist.openChat
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 ActiveCallInteractiveArea(call: Call, newChatSheetState: MutableStateFlow<AnimatedViewState>) {
// if (call.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
if (!newChatSheetState.collectAsState().value.isVisible()) {
val showMenu = remember { mutableStateOf(false) }
val media = call.peerMedia ?: call.localMedia
CompositionLocalProvider(
LocalIndication provides NoIndication
) {
Box(
Modifier
.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Box(
Modifier
.padding(end = 71.dp, bottom = 92.dp)
.size(67.dp)
.combinedClickable(onClick = {
val chat = chatModel.getChat(call.contact.id)
if (chat != null) {
withBGApi {
openChat(chat.remoteHostId, chat.chatInfo, chatModel)
}
}
},
onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true },
contentAlignment = Alignment.Center
) {
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
}
Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) {
if (media == CallMediaType.Video) {
Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White)
} else {
Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White)
}
}
DefaultDropdownMenu(showMenu) {
ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = {
withBGApi { chatModel.callManager.endCall(call) }
showMenu.value = false
})
}
}
}
}
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ defaultAppSettings =
uiDarkColorScheme = Just DCSSimplex,
uiCurrentThemeIds = Nothing,
uiThemes = Nothing,
oneHandUI = Just True
oneHandUI = Just False
}
defaultParseAppSettings :: AppSettings