core, ios: chat tags (#5367)

* types and db

* migration module

* chat tag

* store method proposal

* profiles build

* update type

* update return type

* building

* working api

* update

* refactor

* attach tags to contact

* simplify

* attach chat tags to group info

* get chat tags with supplied user id

* get tags fix

* ios: chat tags poc (#5370)

* ios: chat tags poc

* updates to sheet

* temporary display for other option on swipe

* sheet height

* only show preset when it has matches

* changes

* worst emoji picker ever

* simplify tag casts and collapse

* open on create tag if no tags

* simple emoji text field

* nice emoji picker

* dismiss sheets on tag/untag

* semibold selection

* all preset tag and change collapsed icon on selection

* default selected tag (all)

* only apply tag filters on empty search

* + button when no custom lists

* reset selection of tag filter on profile changes

* edit tag (broken menu inside swiftui list)

* create list to end of list

* swipe changes

* remove context menu

* delete and edit on swipe actions

* tap unread filter deselects other filters

* remove delete tag if empty

* show tag creation sheet when + button pressed

* in memory tag edit

* color, size

* frame

* layout

* refactor

* remove code

* add unread to same unit

* fraction on long press

* nav fixes

* in memory list

* emoji picker improvements

* remove diff

* secondary plus

* stop flickering on chat tags load

* reuse string

* fix reset glitches

* delete destructive

* simplify?

* changes

* api updates

* fix styles on list via swipe

* fixed untag

* update schema

* move user tags loading to get users chat data

* move presets to model

* update preset tags when chats are updated

* style fixes and locate getPresetTags near tags model

---------

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

* deleted contacts and card should not match contact preset

* fix update presets on chat remove

* update migration indices

* fix migration

* not used chat model

* disable button on repeated list name or emoji

* no chats message for search fix

* fix edits and trim

* error in footer, not in alert

* styling fixes due to wrong place to attach sheet

* update library

* remove log

* idea for dynamic sheet height

* max fraction 62%

* minor fixes

* disable save button when no changes and while saving

* disable preset filter if it is no longer shown

* remove comments from schema

* fix emoji

* remove apiChatTagsResponse

* always read chat tags

* fix

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2024-12-19 10:48:26 +00:00
1 parent a73fb89c44
commit fcb2d1dbac
25 files changed
+1311 -116

No files matched your search

@@ -332,7 +332,7 @@ struct ChatInfoView: View {
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
.presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
@@ -8,6 +8,7 @@
import SwiftUI
import SimpleXChat
import ElegantEmojiPicker
typealias DynamicSizes = (
rowHeight: CGFloat,
@@ -43,9 +44,11 @@ func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = false
@ObservedObject var chat: Chat
@Binding var parentSheet: SomeSheet<AnyView>?
@State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false
@@ -85,6 +88,7 @@ struct ChatListNavLink: View {
progressByTimeout = false
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
}
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
@@ -124,6 +128,7 @@ struct ChatListNavLink: View {
toggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
tagChatButton(chat)
if !chat.chatItems.isEmpty {
clearChatButton()
}
@@ -145,11 +150,10 @@ struct ChatListNavLink: View {
}
}
.alert(item: $alert) { $0.alert }
.actionSheet(item: $actionSheet) { $0.actionSheet }
.sheet(item: $sheet) {
if #available(iOS 16.0, *) {
$0.content
.presentationDetents([.fraction(0.4)])
.presentationDetents([.fraction($0.fraction)])
} else {
$0.content
}
@@ -185,6 +189,7 @@ struct ChatListNavLink: View {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
.swipeActions(edge: .trailing) {
tagChatButton(chat)
if (groupInfo.membership.memberCurrent) {
leaveGroupChatButton(groupInfo)
}
@@ -206,14 +211,25 @@ struct ChatListNavLink: View {
toggleNtfsButton(chat: chat)
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
if !chat.chatItems.isEmpty {
tagChatButton(chat)
let showClearButton = !chat.chatItems.isEmpty
let showDeleteGroup = groupInfo.canDelete
let showLeaveGroup = groupInfo.membership.memberCurrent
let totalNumberOfButtons = 1 + (showClearButton ? 1 : 0) + (showDeleteGroup ? 1 : 0) + (showLeaveGroup ? 1 : 0)
if showClearButton, totalNumberOfButtons <= 3 {
clearChatButton()
}
if (groupInfo.membership.memberCurrent) {
if (showLeaveGroup) {
leaveGroupChatButton(groupInfo)
}
if groupInfo.canDelete {
deleteGroupChatButton(groupInfo)
if showDeleteGroup {
if totalNumberOfButtons <= 3 {
deleteGroupChatButton(groupInfo)
} else {
moreOptionsButton(chat, groupInfo)
}
}
}
}
@@ -306,7 +322,67 @@ struct ChatListNavLink: View {
}
.tint(Color.orange)
}
private func tagChatButton(_ chat: Chat) -> some View {
Button {
setTagChatSheet(chat)
} label: {
SwipeLabel(NSLocalizedString("List", comment: "swipe action"), systemImage: "tag.fill", inverted: oneHandUI)
}
.tint(.mint)
}
private func setTagChatSheet(_ chat: Chat) {
let screenHeight = UIScreen.main.bounds.height
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
if chatTagsModel.userTags.isEmpty {
ChatListTagEditor(chat: chat)
} else {
ChatListTag(chat: chat)
}
}
)
},
id: "lists sheet",
fraction: fraction
)
}
private func moreOptionsButton(_ chat: Chat, _ groupInfo: GroupInfo?) -> some View {
Button {
var buttons: [Alert.Button] = [
.default(Text("Clear")) {
AlertManager.shared.showAlert(clearChatAlert())
}
]
if let gi = groupInfo, gi.canDelete {
buttons.append(.destructive(Text("Delete")) {
AlertManager.shared.showAlert(deleteGroupAlert(gi))
})
}
buttons.append(.cancel())
actionSheet = SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Clear or delete group?"),
buttons: buttons
),
id: "other options"
)
} label: {
SwipeLabel(NSLocalizedString("More", comment: "swipe action"), systemImage: "ellipsis", inverted: oneHandUI)
}
}
private func clearNoteFolderButton() -> some View {
Button {
AlertManager.shared.showAlert(clearNoteFolderAlert())
@@ -484,6 +560,389 @@ struct ChatListNavLink: View {
}
}
struct TagEditorNavParams {
let chat: Chat?
let chatListTag: ChatTagData?
let tagId: Int64?
}
struct ChatListTag: View {
var chat: Chat? = nil
var showEditButton: Bool = false
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var m: ChatModel
@State private var editMode = EditMode.inactive
@State private var tagEditorNavParams: TagEditorNavParams? = nil
var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
var body: some View {
List {
Section {
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let text = tag.chatTagText
let emoji = tag.chatTagEmoji
let tagId = tag.chatTagId
let selected = chatTagsIds.contains(tagId)
HStack {
if let emoji {
Text(emoji)
} else {
Image(systemName: "tag")
}
Text(text)
.padding(.leading, 12)
Spacer()
if chat != nil {
radioButton(selected: selected)
}
}
.contentShape(Rectangle())
.onTapGesture {
if let c = chat {
setTag(tagId: selected ? nil : tagId, chat: c)
} else {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default
),
UIAlertAction(
title: NSLocalizedString("Delete", comment: "alert action"),
style: .destructive,
handler: { _ in
deleteTag(tagId)
}
)
]}
)
} label: {
Label("Delete", systemImage: "trash.fill")
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(theme.colors.primary)
}
.background(
// isActive required to navigate to edit view from any possible tag edited in swipe action
NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) {
if let params = tagEditorNavParams {
ChatListTagEditor(
chat: params.chat,
tagId: params.tagId,
emoji: params.chatListTag?.emoji,
name: params.chatListTag?.text ?? ""
)
}
} label: {
EmptyView()
}
.opacity(0)
)
}
.onMove(perform: moveItem)
NavigationLink {
ChatListTagEditor(chat: chat)
} label: {
Label("Create list", systemImage: "plus")
}
} header: {
if showEditButton {
editTagsButton()
.textCase(nil)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.modifier(ThemedBackground(grouped: true))
.environment(\.editMode, $editMode)
}
private func editTagsButton() -> some View {
if editMode.isEditing {
Button("Done") {
editMode = .inactive
dismiss()
}
} else {
Button("Edit") {
editMode = .active
}
}
}
@ViewBuilder private func radioButton(selected: Bool) -> some View {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.imageScale(.large)
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
}
private func moveItem(from source: IndexSet, to destination: Int) {
Task {
do {
var tags = chatTagsModel.userTags
tags.move(fromOffsets: source, toOffset: destination)
try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId })
await MainActor.run {
chatTagsModel.userTags = tags
}
} catch let error {
showAlert(
NSLocalizedString("Error reordering lists", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func setTag(tagId: Int64?, chat: Chat) {
Task {
do {
let tagIds: [Int64] = if let t = tagId { [t] } else {[]}
let (userTags, chatTags) = try await apiSetChatTags(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tagIds: tagIds
)
await MainActor.run {
chatTagsModel.userTags = userTags
if var contact = chat.chatInfo.contact {
contact.chatTags = chatTags
m.updateContact(contact)
} else if var group = chat.chatInfo.groupInfo {
group.chatTags = chatTags
m.updateGroup(group)
}
dismiss()
}
} catch let error {
showAlert(
NSLocalizedString("Error saving chat list", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func deleteTag(_ tagId: Int64) {
Task {
try await apiDeleteChatTag(tagId: tagId)
await MainActor.run {
chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId }
if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId {
chatTagsModel.activeFilter = nil
}
m.chats.forEach { c in
if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) {
contact.chatTags = contact.chatTags.filter({ $0 != tagId })
m.updateContact(contact)
} else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) {
group.chatTags = group.chatTags.filter({ $0 != tagId })
m.updateGroup(group)
}
}
}
}
}
}
struct EmojiPickerView: UIViewControllerRepresentable {
@Binding var selectedEmoji: String?
@Binding var showingPicker: Bool
@Environment(\.presentationMode) var presentationMode
class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate {
var parent: EmojiPickerView
init(parent: EmojiPickerView) {
self.parent = parent
}
func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) {
parent.selectedEmoji = emoji?.emoji
parent.showingPicker = false
picker.dismiss(animated: true)
}
// Called when the picker is dismissed manually (without selection)
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
parent.showingPicker = false
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIViewController {
let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false)
let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config)
picker.presentationController?.delegate = context.coordinator
let viewController = UIViewController()
DispatchQueue.main.async {
if let topVC = getTopViewController() {
topVC.present(picker, animated: true)
}
}
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// No need to update the controller after creation
}
}
struct ChatListTagEditor: View {
var chat: Chat? = nil
var tagId: Int64? = nil
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var theme: AppTheme
var emoji: String?
var name: String = ""
@State private var newEmoji: String?
@State private var newName: String = ""
@State private var isPickerPresented = false
@State private var saving: Bool?
var body: some View {
VStack {
List {
let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in
tag.chatTagId != tagId &&
((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName)
}
Section {
HStack {
Button {
isPickerPresented = true
} label: {
if let newEmoji {
Text(newEmoji)
} else {
Image(systemName: "face.smiling")
.foregroundColor(.secondary)
}
}
TextField("List name...", text: $newName)
}
Button {
saving = true
if let tId = tagId {
updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName))
} else {
createChatTag()
}
} label: {
Text(NSLocalizedString(tagId == nil ? "Create list" : "Save list", comment: "list editor button"))
}
.disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName)
} footer: {
if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering
HStack {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
Text("List name and emoji should be different for all lists.")
.foregroundColor(theme.colors.secondary)
}
}
}
}
if isPickerPresented {
EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented)
}
}
.modifier(ThemedBackground(grouped: true))
.onAppear {
newEmoji = emoji
newName = name
}
}
var trimmedName: String {
newName.trimmingCharacters(in: .whitespaces)
}
private func createChatTag() {
Task {
do {
let userTags = try await apiCreateChatTag(
tag: ChatTagData(emoji: newEmoji , text: trimmedName)
)
await MainActor.run {
saving = false
chatTagsModel.userTags = userTags
dismiss()
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
Task {
do {
try await apiUpdateChatTag(tagId: tagId, tag: chatTagData)
await MainActor.run {
saving = false
for i in 0..<chatTagsModel.userTags.count {
if chatTagsModel.userTags[i].chatTagId == tagId {
chatTagsModel.userTags[i] = ChatTag(
chatTagId: tagId,
chatTagText: chatTagData.text,
chatTagEmoji: chatTagData.emoji
)
}
}
dismiss()
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
@@ -585,15 +1044,15 @@ struct ChatListNavLink_Previews: PreviewProvider {
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
))
), parentSheet: .constant(nil))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello")]
))
), parentSheet: .constant(nil))
ChatListNavLink(chat: Chat(
chatInfo: ChatInfo.sampleData.contactRequest,
chatItems: []
))
), parentSheet: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 82))
}
+308 -56
View File
@@ -31,6 +31,29 @@ enum UserPickerSheet: Identifiable {
}
}
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
case favorites = 0
case contacts = 1
case groups = 2
case business = 3
var id: Int { rawValue }
}
enum ActiveFilter: Identifiable, Equatable {
case presetTag(PresetTag)
case userTag(ChatTag)
case unread
var id: String {
switch self {
case let .presetTag(tag): "preset \(tag.id)"
case let .userTag(tag): "user \(tag.chatTagId)"
case .unread: "unread"
}
}
}
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
}
@@ -117,13 +140,14 @@ struct ChatListView: View {
@State private var searchChatFilteredBySimplexLink: String? = nil
@State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@State private var sheet: SomeSheet<AnyView>? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@AppStorage(DEFAULT_TOOLBAR_MATERIAL) private var toolbarMaterial = ToolbarMaterial.defaultMaterial
var body: some View {
if #available(iOS 16.0, *) {
viewBody.scrollDismissesKeyboard(.immediately)
@@ -131,7 +155,7 @@ struct ChatListView: View {
viewBody
}
}
private var viewBody: some View {
ZStack(alignment: oneHandUI ? .bottomLeading : .topLeading) {
NavStackCompat(
@@ -161,8 +185,9 @@ struct ChatListView: View {
}
}
}
.environmentObject(chatTagsModel)
}
private var chatListView: some View {
let tm = ToolbarMaterial.material(toolbarMaterial)
return withToolbar(tm) {
@@ -197,15 +222,22 @@ struct ChatListView: View {
Divider().padding(.bottom, Self.hasHomeIndicator ? 0 : 8).background(tm)
}
}
.sheet(item: $sheet) { sheet in
if #available(iOS 16.0, *) {
sheet.content.presentationDetents([.fraction(sheet.fraction)])
} else {
sheet.content
}
}
}
static var hasHomeIndicator: Bool = {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.safeAreaInsets.bottom > 0
} else { false }
}()
@ViewBuilder func withToolbar(_ material: Material, content: () -> some View) -> some View {
if #available(iOS 16.0, *) {
if oneHandUI {
@@ -226,13 +258,13 @@ struct ChatListView: View {
}
}
}
@ToolbarContentBuilder var topToolbar: some ToolbarContent {
ToolbarItem(placement: .topBarLeading) { leadingToolbarItem }
ToolbarItem(placement: .principal) { SubsStatusIndicator() }
ToolbarItem(placement: .topBarTrailing) { trailingToolbarItem }
}
@ToolbarContentBuilder var bottomToolbar: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItem(placement: .bottomBar) {
@@ -247,7 +279,7 @@ struct ChatListView: View {
.onTapGesture { scrollToSearchBar = true }
}
}
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItemGroup(placement: .bottomBar) {
@@ -258,7 +290,7 @@ struct ChatListView: View {
trailingToolbarItem.padding(.bottom, padding)
}
}
@ViewBuilder var leadingToolbarItem: some View {
let user = chatModel.currentUser ?? User.sampleData
ZStack(alignment: .topTrailing) {
@@ -275,7 +307,7 @@ struct ChatListView: View {
userPickerShown = true
}
}
@ViewBuilder var trailingToolbarItem: some View {
switch chatModel.chatRunning {
case .some(true): NewChatMenuButton()
@@ -283,7 +315,7 @@ struct ChatListView: View {
case .none: EmptyView()
}
}
@ViewBuilder private var chatList: some View {
let cs = filteredChats()
ZStack {
@@ -295,7 +327,8 @@ struct ChatListView: View {
searchFocussed: $searchFocussed,
searchText: $searchText,
searchShowingSimplexLink: $searchShowingSimplexLink,
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink
searchChatFilteredBySimplexLink: $searchChatFilteredBySimplexLink,
parentSheet: $sheet
)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden)
@@ -306,7 +339,7 @@ struct ChatListView: View {
}
if #available(iOS 16.0, *) {
ForEach(cs, id: \.viewId) { chat in
ChatListNavLink(chat: chat)
ChatListNavLink(chat: chat, parentSheet: $sheet)
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.padding(.trailing, -16)
.disabled(chatModel.chatRunning != true || chatModel.deletedChats.contains(chat.chatInfo.id))
@@ -318,7 +351,7 @@ struct ChatListView: View {
VStack(spacing: .zero) {
Divider()
.padding(.leading, 16)
ChatListNavLink(chat: chat)
ChatListNavLink(chat: chat, parentSheet: $sheet)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
@@ -363,80 +396,97 @@ struct ChatListView: View {
}
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats")
noChatsView()
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.foregroundColor(.secondary)
}
}
}
@ViewBuilder private func noChatsView() -> some View {
if searchString().isEmpty {
switch chatTagsModel.activeFilter {
case .presetTag: Text("No filtered chats") // this should not happen
case let .userTag(tag): Text("No chats in list \(tag.chatTagText)")
case .unread:
Button {
chatTagsModel.activeFilter = nil
} label: {
HStack {
Image(systemName: "line.3.horizontal.decrease")
Text("No unread chats")
}
}
case .none: Text("No chats")
}
} else {
Text("No chats found")
}
}
private func unreadBadge(size: CGFloat = 18) -> some View {
Circle()
.frame(width: size, height: size)
.foregroundColor(theme.colors.primary)
}
@ViewBuilder private func chatView() -> some View {
if let chatId = chatModel.chatId, let chat = chatModel.getChat(chatId) {
ChatView(chat: chat)
}
}
func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
return s == ""
? chatModel.chats.filter { chat in
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat)
}
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
switch cInfo {
return switch cInfo {
case let .direct(contact):
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
s == ""
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
!contact.chatDeleted && !chat.chatInfo.contactCard && (
( 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)
: viewNameContains(cInfo, s)
case .local:
return s == "" || viewNameContains(cInfo, s)
case .contactRequest:
return s == "" || viewNameContains(cInfo, s)
case let .contactConnection(conn):
return s != "" && conn.localAlias.localizedLowercase.contains(s)
case .invalidJSON:
return false
case .group: viewNameContains(cInfo, s)
case .local: viewNameContains(cInfo, s)
case .contactRequest: viewNameContains(cInfo, s)
case let .contactConnection(conn): conn.localAlias.localizedLowercase.contains(s)
case .invalidJSON: false
}
}
}
func searchString() -> String {
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
func filtered(_ chat: Chat) -> Bool {
(chat.chatInfo.chatSettings?.favorite ?? false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
switch chatTagsModel.activeFilter {
case let .presetTag(tag): presetTagMatchesChat(tag, chat)
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
case .none: true
}
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
func searchString() -> String {
searchShowingSimplexLink ? "" : searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
}
}
struct SubsStatusIndicator: View {
@@ -500,18 +550,20 @@ struct SubsStatusIndicator: View {
struct ChatListSearchBar: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@Binding var searchMode: Bool
@FocusState.Binding var searchFocussed: Bool
@Binding var searchText: String
@Binding var searchShowingSimplexLink: Bool
@Binding var searchChatFilteredBySimplexLink: String?
@Binding var parentSheet: SomeSheet<AnyView>?
@State private var ignoreSearchTextChange = 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) {
ScrollView([.horizontal], showsIndicators: false) { ChatTagsView(parentSheet: $parentSheet) }
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
@@ -578,16 +630,21 @@ struct ChatListSearchBar: View {
}
private func toggleFilterButton() -> some View {
ZStack {
let showUnread = chatTagsModel.activeFilter == .unread
return ZStack {
Color.clear
.frame(width: 22, height: 22)
Image(systemName: showUnreadAndFavorites ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
Image(systemName: showUnread ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease")
.resizable()
.scaledToFit()
.foregroundColor(showUnreadAndFavorites ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnreadAndFavorites ? 22 : 16, height: showUnreadAndFavorites ? 22 : 16)
.foregroundColor(showUnread ? theme.colors.primary : theme.colors.secondary)
.frame(width: showUnread ? 22 : 16, height: showUnread ? 22 : 16)
.onTapGesture {
showUnreadAndFavorites = !showUnreadAndFavorites
if chatTagsModel.activeFilter == .unread {
chatTagsModel.activeFilter = nil
} else {
chatTagsModel.activeFilter = .unread
}
}
}
}
@@ -605,6 +662,179 @@ struct ChatListSearchBar: View {
}
}
struct ChatTagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
@Binding var parentSheet: SomeSheet<AnyView>?
var body: some View {
HStack {
tagsView()
}
}
@ViewBuilder private func tagsView() -> some View {
if chatTagsModel.presetTags.count > 1 {
if chatTagsModel.presetTags.count + chatTagsModel.userTags.count <= 3 {
expandedPresetTagsFiltersView()
} else {
collapsedTagsFilterView()
}
}
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let current = if case let .userTag(t) = chatTagsModel.activeFilter {
t == tag
} else {
false
}
let color: Color = current ? .accentColor : .secondary
ZStack {
HStack(spacing: 4) {
if let emoji = tag.chatTagEmoji {
Text(emoji)
} else {
Image(systemName: current ? "tag.fill" : "tag")
.foregroundColor(color)
}
ZStack {
Text(tag.chatTagText).fontWeight(.semibold).foregroundColor(.clear)
Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .userTag(tag))
}
.onLongPressGesture {
let screenHeight = UIScreen.main.bounds.height
let reservedSpace: Double = 4 * 44 // 2 for padding, 1 for "Create list" and another for extra tag
let tagsSpace = Double(max(chatTagsModel.userTags.count, 3)) * 44
let fraction = min((reservedSpace + tagsSpace) / screenHeight, 0.62)
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTag(chat: nil, showEditButton: true)
.modifier(ThemedBackground(grouped: true))
}
)
},
id: "tag list",
fraction: fraction
)
}
}
}
Button {
parentSheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTagEditor()
}
)
},
id: "tag create"
)
} label: {
if chatTagsModel.userTags.isEmpty {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add list")
}
} else {
Image(systemName: "plus")
}
}
.foregroundColor(.secondary)
}
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
ForEach(chatTagsModel.presetTags, id: \.id) { tag in
let active = tag == selectedPresetTag
let (icon, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack(spacing: 4) {
Image(systemName: icon)
.foregroundColor(color)
ZStack {
Text(text).fontWeight(.semibold).foregroundColor(.clear)
Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .presetTag(tag))
}
}
}
@ViewBuilder private func collapsedTagsFilterView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
Menu {
if selectedPresetTag != nil {
Button {
chatTagsModel.activeFilter = nil
} label: {
HStack {
Image(systemName: "list.bullet")
Text("All")
}
}
}
ForEach(chatTagsModel.presetTags, id: \.id) { tag in
Button {
setActiveFilter(filter: .presetTag(tag))
} label: {
let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
HStack {
Image(systemName: systemName)
Text(text)
}
}
}
} label: {
if let tag = selectedPresetTag {
let (systemName, _) = presetTagLabel(tag: tag, active: true)
Image(systemName: systemName)
.foregroundColor(.accentColor)
} else {
Image(systemName: "list.bullet")
.foregroundColor(.secondary)
}
}
.frame(minWidth: 28)
}
private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) {
switch tag {
case .favorites: (active ? "star.fill" : "star", "Favorites")
case .contacts: (active ? "person.fill" : "person", "Contacts")
case .groups: (active ? "person.2.fill" : "person.2", "Groups")
case .business: (active ? "briefcase.fill" : "briefcase", "Businesses")
}
}
private func setActiveFilter(filter: ActiveFilter) {
if filter != chatTagsModel.activeFilter {
chatTagsModel.activeFilter = filter
} else {
chatTagsModel.activeFilter = nil
}
}
}
func chatStoppedIcon() -> some View {
Button {
AlertManager.shared.showAlertMsg(
@@ -616,6 +846,28 @@ func chatStoppedIcon() -> some View {
}
}
func presetTagMatchesChat(_ tag: PresetTag, _ chat: Chat) -> Bool {
switch tag {
case .favorites:
chat.chatInfo.chatSettings?.favorite == true
case .contacts:
switch chat.chatInfo {
case let .direct(contact): !(contact.activeConn == nil && contact.profile.contactLink != nil && contact.active) && !contact.chatDeleted
case .contactRequest: true
case .contactConnection: true
case let .group(groupInfo): groupInfo.businessChat?.chatType == .customer
default: false
}
case .groups:
switch chat.chatInfo {
case let .group(groupInfo): groupInfo.businessChat == nil
default: false
}
case .business:
chat.chatInfo.groupInfo?.businessChat?.chatType == .business
}
}
struct ChatListView_Previews: PreviewProvider {
@State static var userPickerSheet: UserPickerSheet? = .none
@@ -20,7 +20,7 @@ struct ContactListNavLink: View {
@State private var showContactRequestDialog = false
var body: some View {
let contactType = chatContactType(chat: chat)
let contactType = chatContactType(chat)
Group {
switch (chat.chatInfo) {
@@ -186,7 +186,7 @@ struct NewChatSheet: View {
}
}
func chatContactType(chat: Chat) -> ContactType {
func chatContactType(_ chat: Chat) -> ContactType {
switch chat.chatInfo {
case .contactRequest:
return .request
@@ -207,7 +207,7 @@ func chatContactType(chat: Chat) -> ContactType {
private func filterContactTypes(chats: [Chat], contactTypes: [ContactType]) -> [Chat] {
return chats.filter { chat in
contactTypes.contains(chatContactType(chat: chat))
contactTypes.contains(chatContactType(chat))
}
}
@@ -279,8 +279,8 @@ struct ContactsList: View {
}
private func chatsByTypeComparator(chat1: Chat, chat2: Chat) -> Bool {
let chat1Type = chatContactType(chat: chat1)
let chat2Type = chatContactType(chat: chat2)
let chat1Type = chatContactType(chat1)
let chat2Type = chatContactType(chat2)
if chat1Type.rawValue < chat2Type.rawValue {
return true
@@ -25,6 +25,7 @@ struct SomeActionSheet: Identifiable {
struct SomeSheet<Content: View>: Identifiable {
@ViewBuilder var content: Content
var id: String
var fraction = 0.4
}
private enum NewChatViewAlert: Identifiable {
@@ -21,7 +21,7 @@ struct AddressCreationCard: View {
var body: some View {
let addressExists = chatModel.userAddress != nil
let chats = chatModel.chats.filter { chat in
!chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard
}
ZStack(alignment: .topTrailing) {
HStack(alignment: .top, spacing: 16) {