This commit is contained in:
Diogo
2024-12-16 22:39:09 +00:00
parent 11aa5b450a
commit 498c60740e
4 changed files with 276 additions and 269 deletions
+2 -12
View File
@@ -103,18 +103,8 @@ class ItemsModel: ObservableObject {
class ChatTagsModel: ObservableObject {
static let shared = ChatTagsModel()
static let defaultTag: ChatTagFilter = .presetTag(icon: "list.bullet", activeIcon: "list.bullet", text: NSLocalizedString("All", comment: "preset chat list"), enabled: true, filter: { c in true })
let presetTags: [ChatTagFilter] = [
defaultTag,
.presetTag(icon: "star", activeIcon: "star.fill", text: NSLocalizedString("Favorite", comment: "preset chat list"), enabled: false, filter: { c in c.chatSettings?.favorite ?? false }),
.presetTag(icon: "person", activeIcon: "person.fill", text: NSLocalizedString("Contacts", comment: "preset chat list"), enabled: false, filter: { if case .direct = $0 { true } else { false }}),
.presetTag(icon: "person.2", activeIcon: "person.2.fill", text: NSLocalizedString("Groups", comment: "preset chat list"), enabled: false, filter: { filterGroupChat($0) }),
.presetTag(icon: "briefcase", activeIcon: "briefcase.fill", text: NSLocalizedString("Business", comment: "preset chat list"), enabled: false, filter: { filterBusinessChat($0) })
]
@Published var tags: [ChatTagFilter] = []
@Published var selectedTag: ChatTagFilter = defaultTag
@Published var userTags: [ChatTag] = []
@Published var activeFilter: ActiveFilter? = nil
}
private func filterBusinessChat(_ cInfo: ChatInfo) -> Bool {
@@ -335,7 +335,7 @@ struct ChatListNavLink: View {
private func setTagChatSheet(_ chat: Chat) {
let fraction: Double
switch chatTagsModel.tags.count {
switch chatTagsModel.userTags.count {
case 0..<4:
fraction = 0.35
case 4..<9:
@@ -348,7 +348,7 @@ struct ChatListNavLink: View {
content: {
AnyView(
NavigationView {
if chatTagsModel.tags.isEmpty {
if chatTagsModel.userTags.isEmpty {
ChatListTagEditor(chat: chat)
} else {
ChatListTag(chat: chat)
@@ -585,70 +585,67 @@ struct ChatListTag: View {
var body: some View {
List {
if !chatTagsModel.tags.isEmpty {
ForEach(chatTagsModel.tags) { tag in
if case let .chatTag(emoji, text, tagId) = tag {
let selected = chatTagsIds.contains(tagId)
HStack {
Text(emoji)
Text(text)
.padding(.leading, 12)
Spacer()
if chat != nil {
radioButton(selected: selected)
}
}
.onTapGesture {
if let c = chat {
if selected {
untagChat(tagId: tagId, chat: c)
} else {
// TODO: Temporary until API is final.
chatTagsIds.forEach { t in
untagChat(tagId: t, chat: c)
}
tagChat(tagId: tagId, chat: c)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("\(text) will be 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: {
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: false)
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
navigateToTagEditor = true
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
} label: {
SwipeLabel(NSLocalizedString("Edit", comment: "swipe action"), systemImage: "pencil", inverted: false)
}
.tint(theme.colors.primary)
}
} else {
EmptyView()
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 {
Text(emoji)
Text(text)
.padding(.leading, 12)
Spacer()
if chat != nil {
radioButton(selected: selected)
}
}
.onTapGesture {
if let c = chat {
if selected {
untagChat(tagId: tagId, chat: c)
} else {
// TODO: Temporary until API is final.
chatTagsIds.forEach { t in
untagChat(tagId: t, chat: c)
}
tagChat(tagId: tagId, chat: c)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("\(text) will be 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: {
SwipeLabel(NSLocalizedString("Delete", comment: "swipe action"), systemImage: "trash.fill", inverted: false)
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
navigateToTagEditor = true
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
} label: {
SwipeLabel(NSLocalizedString("Edit", comment: "swipe action"), systemImage: "pencil", inverted: false)
}
.tint(theme.colors.primary)
}
}
// isActive required since there are 2 possible ways to navigate to editor view. 1 by click, 2 by swipe in any existing tag
NavigationLink(isActive: $navigateToTagEditor) {
@@ -687,9 +684,7 @@ struct ChatListTag: View {
)
await MainActor.run {
chatTagsModel.tags = userTags.map {
.chatTag(emoji: $0.chatTagEmoji, text: $0.chatTagText, tagId: $0.chatTagId)
}
chatTagsModel.userTags = userTags
updateChatTags(chat: chat, chatTags: chatTags)
dismiss()
}
@@ -712,12 +707,10 @@ struct ChatListTag: View {
)
await MainActor.run {
chatTagsModel.tags = userTags.map {
.chatTag(emoji: $0.chatTagEmoji, text: $0.chatTagText, tagId: $0.chatTagId)
}
if case let .chatTag(_, _, tagId) = chatTagsModel.selectedTag,
!userTags.contains(where: { $0.chatTagId == tagId }) {
chatTagsModel.selectedTag = ChatTagsModel.defaultTag
chatTagsModel.userTags = userTags
if case let .userTag(tag) = chatTagsModel.activeFilter,
!userTags.contains(where: { $0.chatTagId == tag.chatTagId }) {
chatTagsModel.activeFilter = nil
}
updateChatTags(chat: chat, chatTags: chatTags)
dismiss()
@@ -736,17 +729,10 @@ struct ChatListTag: View {
// TODO: Delete tag API
Task {
await MainActor.run {
chatTagsModel.tags = chatTagsModel.tags.filter({ tag in
if case let .chatTag(_, _, id) = tag {
return id != tagId
} else {
return true
}
})
if case let .chatTag(_, _, stId) = chatTagsModel.selectedTag, stId == tagId {
chatTagsModel.selectedTag = ChatTagsModel.defaultTag
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 })
@@ -882,9 +868,7 @@ struct ChatListTagEditor: View {
)
await MainActor.run {
chatTagsModel.tags = userTags.map {
.chatTag(emoji: $0.chatTagEmoji, text: $0.chatTagText, tagId: $0.chatTagId)
}
chatTagsModel.userTags = userTags
if var contact = chat.chatInfo.contact {
contact.chatTags = chatTags
m.updateContact(contact)
@@ -909,9 +893,9 @@ struct ChatListTagEditor: View {
Task {
do {
await MainActor.run {
chatTagsModel.tags = chatTagsModel.tags.map { tag in
if case let .chatTag(_, _, tId) = tag, tId == tagId {
.chatTag(emoji: chatTagData.emoji, text: chatTagData.text, tagId: tId)
chatTagsModel.userTags = chatTagsModel.userTags.map { tag in
if tag.chatTagId == tagId {
ChatTag(chatTagId: tag.chatTagId, chatTagText: chatTagData.text, chatTagEmoji: chatTagData.emoji)
} else {
tag
}
+193 -168
View File
@@ -31,6 +31,38 @@ enum UserPickerSheet: Identifiable {
}
}
enum PresetTag: Identifiable, Equatable {
case contact
case group
case business
case favorite
var id: PresetTag { self }
var order: Int {
switch self {
case .contact: return 0
case .group: return 1
case .business: return 2
case .favorite: return 3
}
}
}
enum ActiveFilter: Identifiable, Equatable {
case presetTag(tag: PresetTag)
case userTag(tag: ChatTag)
case unread
var id: String {
switch self {
case let .presetTag(tag): return "preset \(tag.id)"
case let .userTag(tag): return "user \(tag.chatTagId)"
case .unread: return "unread"
}
}
}
class SaveableSettings: ObservableObject {
@Published var servers: ServerSettings = ServerSettings(currUserServers: [], userServers: [], serverErrors: [])
}
@@ -118,6 +150,7 @@ struct ChatListView: View {
@State private var scrollToSearchBar = false
@State private var userPickerShown: Bool = false
@State private var sheet: SomeSheet<AnyView>? = nil
@State private var presetTags: [PresetTag] = []
@StateObject private var chatTagsModel = ChatTagsModel.shared
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
@@ -369,11 +402,6 @@ struct ChatListView: View {
.onChange(of: chatModel.currentUser?.userId) { _ in
stopAudioPlayer()
}
.onChange(of: showUnreadAndFavorites) { show in
if show {
chatTagsModel.selectedTag = ChatTagsModel.defaultTag
}
}
.onChange(of: scrollToSearchBar) { scrollToSearchBar in
if scrollToSearchBar {
Task { self.scrollToSearchBar = false }
@@ -405,15 +433,14 @@ struct ChatListView: View {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
return chatModel.chats.filter { $0.id == linkChatId}
} else {
let s = searchString()
return s == "" && !showUnreadAndFavorites
let chats = s == "" && !showUnreadAndFavorites
? chatModel.chats.filter { chat in
filterByTag(chat) && !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card
return !chat.chatInfo.chatDeleted && chatContactType(chat: chat) != ContactType.card && filtered(chat)
}
: chatModel.chats.filter { chat in
let cInfo = chat.chatInfo
@@ -421,25 +448,27 @@ struct ChatListView: View {
case let .direct(contact):
return !contact.chatDeleted && chatContactType(chat: chat) != ContactType.card && (
s == ""
? filterByTag(chat) && filtered(chat)
? filtered(chat)
: (viewNameContains(cInfo, s) ||
contact.profile.displayName.localizedLowercase.contains(s) ||
contact.fullName.localizedLowercase.contains(s))
)
case let .group(gInfo):
return s == ""
? filterByTag(chat) && (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
? (filtered(chat) || gInfo.membership.memberStatus == .memInvited)
: viewNameContains(cInfo, s)
case .local:
return s == "" ? filterByTag(chat) : viewNameContains(cInfo, s)
return s == "" || viewNameContains(cInfo, s)
case .contactRequest:
return s == "" ? filterByTag(chat) : viewNameContains(cInfo, s)
return s == "" || viewNameContains(cInfo, s)
case let .contactConnection(conn):
return s == "" ? filterByTag(chat) : conn.localAlias.localizedLowercase.contains(s)
return s != "" && conn.localAlias.localizedLowercase.contains(s)
case .invalidJSON:
return false
}
}
return chats
}
func searchString() -> String {
@@ -447,13 +476,24 @@ struct ChatListView: View {
}
func filtered(_ chat: Chat) -> Bool {
(chat.chatInfo.chatSettings?.favorite ?? false) ||
chat.chatStats.unreadChat ||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
}
func filterByTag(_ chat: Chat) -> Bool {
chatTagsModel.selectedTag.filter(chat.chatInfo)
switch chatTagsModel.activeFilter {
case let .presetTag(tag):
let presetTagMatches = chatPresetTagMatches(chat)
return presetTagMatches.contains(tag)
case let .userTag(tag):
switch chat.chatInfo {
case let .direct(contact):
return contact.chatTags.contains(tag.chatTagId)
case let .group(gInfo):
return gInfo.chatTags.contains(tag.chatTagId)
default:
return false
}
case .unread:
return chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
case .none:
return true
}
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
@@ -630,115 +670,66 @@ struct ChatListSearchBar: View {
}
}
enum ChatTagFilter: Identifiable, Equatable {
static func == (lhs: ChatTagFilter, rhs: ChatTagFilter) -> Bool {
switch (lhs, rhs) {
case let (.presetTag(icon1, activeIcon1, text1, enabled1, _), .presetTag(icon2, activeIcon2, text2, enabled2, _)):
return icon1 == icon2 && activeIcon1 == activeIcon2 && text1 == text2 && enabled1 == enabled2
case let (.chatTag(emoji1, text1, tagId1), .chatTag(emoji2, text2, tagId2)):
return emoji1 == emoji2 && text1 == text2 && tagId1 == tagId2
default:
return false
}
}
case presetTag(icon: String, activeIcon: String, text: String, enabled: Bool, filter: (_ cInfo: ChatInfo) -> Bool)
case chatTag(emoji: String, text: String, tagId: Int64)
public var id: String {
switch self {
case let .presetTag(icon, _, _, _, _): "preset \(icon)"
case let .chatTag(emoji, _, _): "chatTag \(emoji)"
}
}
public var text: String {
switch self {
case let .presetTag(_, _, text, _, _): return text
case let .chatTag(_, text, _): return text
}
}
public var enabled: Bool {
switch self {
case let .presetTag(_, _, _, enabled, _): return enabled
case .chatTag: return true
}
}
public func getIcon(active: Bool) -> String {
switch self {
case let .presetTag(icon, activeIcon, _, _, _): return active ? activeIcon : icon
case let .chatTag(emoji, _, _): return emoji
}
}
public func filter(_ cInfo: ChatInfo) -> Bool {
switch self {
case let .presetTag(_, _, _, _, filter): return filter(cInfo)
case let .chatTag(_, _, tagId):
switch cInfo {
case let .direct(contact):
return contact.chatTags.contains(tagId)
case let .group(gInfo):
return gInfo.chatTags.contains(tagId)
default:
return false
}
}
}
}
struct ChatTagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
@State private var sheet: SomeSheet<AnyView>? = nil
var presetTags: [PresetTag] {
var matches = Set<PresetTag>()
for chat in chatModel.chats {
matches.formUnion(chatPresetTagMatches(chat))
if matches.count == 4 {
break
}
}
return Array(matches).sorted(by: { $0.order < $1.order })
}
var body: some View {
HStack {
let presetFilters = presetTagFilters()
let enabledPresetFilters = presetFilters.filter { $0.enabled }
if enabledPresetFilters.count + chatTagsModel.tags.count <= 5 {
expandedPresetTagsFiltersView(presetFilters)
if presetTags.count + chatTagsModel.userTags.count <= 5 {
expandedPresetTagsFiltersView()
} else {
collapsedTagsFilterView(enabledPresetFilters)
collapsedTagsFilterView()
}
ForEach(chatTagsModel.tags, id: \.id) { tag in
let current = chatTagsModel.selectedTag == tag
let color: Color? = current ? .accentColor : nil
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let current = if case let .userTag(t) = chatTagsModel.activeFilter, t == tag {
true
} else {
false
}
let color: Color = current ? .accentColor : .secondary
ZStack {
switch tag {
case .presetTag:
EmptyView()
case let .chatTag(emoji, text, _):
HStack(spacing: 4) {
Text(emoji)
ZStack {
Text(text).fontWeight(.semibold).foregroundColor(.clear)
Text(text).fontWeight(current ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setSelectedTag(tag)
}
.onLongPressGesture {
sheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTag()
}
)
},
id: "tag list"
)
HStack(spacing: 4) {
Text(tag.chatTagEmoji)
ZStack {
Text(tag.chatTagText).fontWeight(.semibold).foregroundColor(.clear)
Text(tag.chatTagText).fontWeight(current ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .userTag(tag: tag))
}
.onLongPressGesture {
sheet = SomeSheet(
content: {
AnyView(
NavigationView {
ChatListTag()
}
)
},
id: "tag list"
)
}
}
}
if chatTagsModel.tags.isEmpty {
if chatTagsModel.userTags.isEmpty {
Button {
sheet = SomeSheet(
content: {
@@ -777,76 +768,88 @@ struct ChatTagsView: View {
}
}
@ViewBuilder private func expandedPresetTagsFiltersView(_ filters: [ChatTagFilter]) -> some View {
ForEach(filters, id: \.id) { tag in
let current = chatTagsModel.selectedTag == tag
let color: Color? = current ? .accentColor : (tag.enabled ? nil : .secondary)
if chatTagsModel.tags.isEmpty || tag.enabled {
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
ForEach(presetTags, id: \.id) { tag in
let active = tag == selectedPresetTag
let (systemName, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack {
Image(systemName: systemName)
.foregroundColor(color)
ZStack {
Image(systemName: tag.getIcon(active: chatTagsModel.selectedTag == tag))
.foregroundColor(color)
Text(text).fontWeight(.semibold).foregroundColor(.clear)
Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
}
.onTapGesture {
if tag.enabled {
setSelectedTag(tag)
}
}
} else {
EmptyView()
}
.onTapGesture {
setActiveFilter(filter: .presetTag(tag: tag))
}
}
}
@ViewBuilder private func collapsedTagsFilterView(_ filters: [ChatTagFilter]) -> some View {
let selectedPresetTag = filters.first(where: { $0 == chatTagsModel.selectedTag })
let presetFilterIsSelected = selectedPresetTag != nil
let color: Color = presetFilterIsSelected ? .accentColor : .secondary
@ViewBuilder private func collapsedTagsFilterView() -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
Menu {
ForEach(filters, id: \.id) { tag in
ForEach(presetTags, id: \.id) { tag in
Button {
setSelectedTag(tag)
setActiveFilter(filter: .presetTag(tag: tag))
} label: {
let (systemName, text) = presetTagLabel(tag: tag, active: tag == selectedPresetTag)
HStack {
Image(systemName: tag.getIcon(active: chatTagsModel.selectedTag == tag))
Text(tag.text)
Image(systemName: systemName)
Text(text)
}
}
}
Button {
chatTagsModel.activeFilter = nil
} label: {
let (systemName, text) = presetTagLabel(tag: nil, active: false)
HStack {
Image(systemName: systemName)
Text(text)
}
}
} label: {
Image(systemName: selectedPresetTag?.getIcon(active: true) ?? "list.bullet")
.foregroundColor(color)
let active = selectedPresetTag != nil
let (systemName, _) = presetTagLabel(tag: selectedPresetTag, active: active)
Image(systemName: systemName)
.foregroundColor(active ? .accentColor : .secondary)
}
.frame(minWidth: 28)
}
private func presetTagFilters() -> [ChatTagFilter] {
var tags: [ChatTagFilter] = chatTagsModel.presetTags
var filtersLeft = chatTagsModel.presetTags
for chat in chatModel.chats {
for filter in filtersLeft {
if case let .presetTag(icon, activeIcon, text, _, filterFunction) = filter {
if let i = tags.firstIndex(where: { $0.id == filter.id }), filterFunction(chat.chatInfo) {
tags[i] = .presetTag(icon: icon, activeIcon: activeIcon, text: text, enabled: true, filter: filterFunction)
}
}
}
filtersLeft.removeAll { fl in
tags.contains(where: { $0.id == fl.id && $0.enabled } )
}
if filtersLeft.isEmpty {
break
}
private func presetTagLabel(tag: PresetTag?, active: Bool) -> (String, LocalizedStringKey) {
switch tag {
case .contact:
return (active ? "person.fill" : "person", LocalizedStringKey("Contacts"))
case .group:
return (active ? "person.2.fill" : "person.2", LocalizedStringKey("Groups"))
case .business:
return (active ? "briefcase.fill" : "briefcase", LocalizedStringKey("Businesses"))
case .favorite:
return (active ? "star.fill" : "star", LocalizedStringKey("Favorites"))
case .none:
return ("list.bullet", LocalizedStringKey("All"))
}
return tags
}
private func setSelectedTag(_ tag: ChatTagFilter) {
if (chatTagsModel.selectedTag == tag) {
chatTagsModel.selectedTag = ChatTagsModel.defaultTag
private func setActiveFilter(filter: ActiveFilter) {
if filter != chatTagsModel.activeFilter {
chatTagsModel.activeFilter = filter
} else {
chatTagsModel.selectedTag = tag
chatTagsModel.activeFilter = nil
}
}
@@ -856,10 +859,8 @@ struct ChatTagsView: View {
let chatTags = try await apiGetChatTags()
await MainActor.run {
self.chatTagsModel.tags = chatTags.map {
.chatTag(emoji: $0.chatTagEmoji, text: $0.chatTagText, tagId: $0.chatTagId)
}
self.chatTagsModel.selectedTag = ChatTagsModel.defaultTag
self.chatTagsModel.userTags = chatTags
self.chatTagsModel.activeFilter = nil
}
} catch let error {
AlertManager.shared.showAlertMsg(title: "Error", message: "\(responseError(error))")
@@ -879,6 +880,30 @@ func chatStoppedIcon() -> some View {
}
}
private func chatPresetTagMatches(_ chat: Chat) -> Set<PresetTag> {
var matchedPresets = Set<PresetTag>()
switch chat.chatInfo {
case .direct:
matchedPresets.insert(.contact)
if chat.chatInfo.chatSettings?.favorite ?? false {
matchedPresets.insert(.favorite)
}
case let .group(groupInfo):
if groupInfo.businessChat?.chatType == .business {
matchedPresets.insert(.business)
} else {
matchedPresets.insert(.group)
}
if chat.chatInfo.chatSettings?.favorite ?? false {
matchedPresets.insert(.favorite)
}
default:
break
}
return matchedPresets
}
struct ChatListView_Previews: PreviewProvider {
@State static var userPickerSheet: UserPickerSheet? = .none
+8
View File
@@ -4218,6 +4218,14 @@ public struct ChatTag: Decodable, Hashable {
public var chatTagId: Int64
public var chatTagText: String
public var chatTagEmoji: String
public var id: Int64 { chatTagId }
public init(chatTagId: Int64, chatTagText: String, chatTagEmoji: String) {
self.chatTagId = chatTagId
self.chatTagText = chatTagText
self.chatTagEmoji = chatTagEmoji
}
}
public struct ChatItemInfo: Decodable, Hashable {