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:
Diogo 2024-12-19 10:48:26 +00:00 committed by GitHub
parent a73fb89c44
commit fcb2d1dbac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1311 additions and 116 deletions

View file

@ -100,6 +100,34 @@ class ItemsModel: ObservableObject {
}
}
class ChatTagsModel: ObservableObject {
static let shared = ChatTagsModel()
@Published var userTags: [ChatTag] = []
@Published var activeFilter: ActiveFilter? = nil
@Published var presetTags: [PresetTag] = []
}
func updatePresetTags(_ chats: [Chat]) {
var matches: Set<PresetTag> = []
for chat in chats {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chat) {
matches.insert(tag)
}
}
if matches.count == PresetTag.allCases.count {
break
}
}
let tm = ChatTagsModel.shared
if case let .presetTag(tag) = tm.activeFilter, !matches.contains(tag) {
tm.activeFilter = nil
}
tm.presetTags = Array(matches).sorted(by: { $0.rawValue < $1.rawValue })
}
class NetworkModel: ObservableObject {
// map of connections network statuses, key is agent connection id
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
@ -342,8 +370,10 @@ final class ChatModel: ObservableObject {
private func updateChat(_ cInfo: ChatInfo, addMissing: Bool = true) {
if hasChat(cInfo.id) {
updateChatInfo(cInfo)
updatePresetTags(self.chats)
} else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: []))
updatePresetTags(self.chats)
}
}
@ -858,6 +888,7 @@ final class ChatModel: ObservableObject {
func removeChat(_ id: String) {
withAnimation {
chats.removeAll(where: { $0.id == id })
updatePresetTags(chats)
}
}

View file

@ -318,6 +318,20 @@ private func apiChatsResponse(_ r: ChatResponse) throws -> [ChatData] {
throw r
}
func apiGetChatTags() throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
let r = chatSendCmdSync(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
throw r
}
func apiGetChatTagsAsync() async throws -> [ChatTag] {
let userId = try currentUserId("apiGetChatTags")
let r = await chatSendCmd(.apiGetChatTags(userId: userId))
if case let .chatTags(_, tags) = r { return tags }
throw r
}
let loadItemsPerPage = 50
func apiGetChat(type: ChatType, id: Int64, search: String = "") async throws -> Chat {
@ -368,6 +382,34 @@ func apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: Ch
return await processSendMessageCmd(toChatType: toChatType, cmd: cmd)
}
func apiCreateChatTag(tag: ChatTagData) async throws -> [ChatTag] {
let r = await chatSendCmd(.apiCreateChatTag(tag: tag))
if case let .chatTags(_, userTags) = r {
return userTags
}
throw r
}
func apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64]) async throws -> ([ChatTag], [Int64]) {
let r = await chatSendCmd(.apiSetChatTags(type: type, id: id, tagIds: tagIds))
if case let .tagsUpdated(_, userTags, chatTags) = r {
return (userTags, chatTags)
}
throw r
}
func apiDeleteChatTag(tagId: Int64) async throws {
try await sendCommandOkResp(.apiDeleteChatTag(tagId: tagId))
}
func apiUpdateChatTag(tagId: Int64, tag: ChatTagData) async throws {
try await sendCommandOkResp(.apiUpdateChatTag(tagId: tagId, tagData: tag))
}
func apiReorderChatTags(tagIds: [Int64]) async throws {
try await sendCommandOkResp(.apiReorderChatTags(tagIds: tagIds))
}
func apiSendMessages(type: ChatType, id: Int64, live: Bool = false, ttl: Int? = nil, composedMessages: [ComposedMessage]) async -> [ChatItem]? {
let cmd: ChatCommand = .apiSendMessages(type: type, id: id, live: live, ttl: ttl, composedMessages: composedMessages)
return await processSendMessageCmd(toChatType: type, cmd: cmd)
@ -1746,24 +1788,37 @@ func getUserChatData() throws {
m.userAddress = try apiGetUserAddress()
m.chatItemTTL = try getChatItemTTL()
let chats = try apiGetChats()
let tags = try apiGetChatTags()
m.updateChats(chats)
let tm = ChatTagsModel.shared
tm.activeFilter = nil
tm.userTags = tags
updatePresetTags(m.chats)
}
private func getUserChatDataAsync() async throws {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
if m.currentUser != nil {
let userAddress = try await apiGetUserAddressAsync()
let chatItemTTL = try await getChatItemTTLAsync()
let chats = try await apiGetChatsAsync()
let tags = try await apiGetChatTagsAsync()
await MainActor.run {
m.userAddress = userAddress
m.chatItemTTL = chatItemTTL
m.updateChats(chats)
tm.activeFilter = nil
tm.userTags = tags
updatePresetTags(m.chats)
}
} else {
await MainActor.run {
m.userAddress = nil
m.updateChats([])
tm.activeFilter = nil
tm.userTags = []
tm.presetTags = []
}
}
}

View file

@ -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
}

View file

@ -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))
}

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

View file

@ -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) {

View file

@ -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

View file

@ -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 {

View file

@ -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) {

View file

@ -203,6 +203,7 @@
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; };
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
B79ADAFF2CE4EF930083DFFD /* AddressCreationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */; };
@ -636,6 +637,7 @@
buildActionMask = 2147483647;
files = (
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */,
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */,
8C8118722C220B5B00E6FC94 /* Yams in Frameworks */,
8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */,
D741547829AF89AF0022400A /* StoreKit.framework in Frameworks */,
@ -1186,6 +1188,7 @@
D7197A1729AE89660055C05A /* WebRTC */,
8C8118712C220B5B00E6FC94 /* Yams */,
8CB3476B2CF5CFFA006787A5 /* Ink */,
B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */,
);
productName = "SimpleX (iOS)";
productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */;
@ -1330,6 +1333,7 @@
D7197A1629AE89660055C05A /* XCRemoteSwiftPackageReference "WebRTC" */,
8C73C1162C21E17B00892670 /* XCRemoteSwiftPackageReference "Yams" */,
8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */,
B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */,
);
productRefGroup = 5CA059CB279559F40002BEB4 /* Products */;
projectDirPath = "";
@ -2387,6 +2391,14 @@
version = 0.6.0;
};
};
B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/Finalet/Elegant-Emoji-Picker";
requirement = {
branch = main;
kind = branch;
};
};
D7197A1629AE89660055C05A /* XCRemoteSwiftPackageReference "WebRTC" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/simplex-chat/WebRTC.git";
@ -2429,6 +2441,11 @@
package = 8CB3476A2CF5CFFA006787A5 /* XCRemoteSwiftPackageReference "ink" */;
productName = Ink;
};
B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */ = {
isa = XCSwiftPackageProductDependency;
package = B72894592D0C62BF00F7A19A /* XCRemoteSwiftPackageReference "Elegant-Emoji-Picker" */;
productName = ElegantEmojiPicker;
};
CE38A29B2C3FCD72005ED185 /* SwiftyGif */ = {
isa = XCSwiftPackageProductDependency;
package = D77B92DA2952372200A5A1CC /* XCRemoteSwiftPackageReference "SwiftyGif" */;

View file

@ -1,5 +1,5 @@
{
"originHash" : "33afc44be5f4225325b3cb940ed71b6cbf3ef97290d348d7b6803697bcd0637d",
"originHash" : "07434ae88cbf078ce3d27c91c1f605836aaebff0e0cef5f25317795151c77db1",
"pins" : [
{
"identity" : "codescanner",
@ -10,6 +10,15 @@
"version" : "2.5.0"
}
},
{
"identity" : "elegant-emoji-picker",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Finalet/Elegant-Emoji-Picker",
"state" : {
"branch" : "main",
"revision" : "71d2d46092b4d550cc593614efc06438f845f6e6"
}
},
{
"identity" : "ink",
"kind" : "remoteSourceControl",

View file

@ -40,10 +40,16 @@ public enum ChatCommand {
case testStorageEncryption(key: String)
case apiSaveSettings(settings: AppSettings)
case apiGetSettings(settings: AppSettings)
case apiGetChatTags(userId: Int64)
case apiGetChats(userId: Int64)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatTag(tag: ChatTagData)
case apiSetChatTags(type: ChatType, id: Int64, tagIds: [Int64])
case apiDeleteChatTag(tagId: Int64)
case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData)
case apiReorderChatTags(tagIds: [Int64])
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
@ -198,6 +204,7 @@ public enum ChatCommand {
case let .testStorageEncryption(key): return "/db test key \(key)"
case let .apiSaveSettings(settings): return "/_save app settings \(encodeJSON(settings))"
case let .apiGetSettings(settings): return "/_get app settings \(encodeJSON(settings))"
case let .apiGetChatTags(userId): return "/_get tags \(userId)"
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
@ -206,6 +213,11 @@ public enum ChatCommand {
let msgs = encodeJSON(composedMessages)
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msgs)"
case let .apiCreateChatTag(tag): return "/_create tag \(encodeJSON(tag))"
case let .apiSetChatTags(type, id, tagIds): return "/_tags \(ref(type, id)) \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiDeleteChatTag(tagId): return "/_delete tag \(tagId)"
case let .apiUpdateChatTag(tagId, tagData): return "/_update tag \(tagId) \(encodeJSON(tagData))"
case let .apiReorderChatTags(tagIds): return "/_reorder tags \(tagIds.map({ "\($0)" }).joined(separator: ","))"
case let .apiCreateChatItems(noteFolderId, composedMessages):
let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msgs)"
@ -367,10 +379,16 @@ public enum ChatCommand {
case .testStorageEncryption: return "testStorageEncryption"
case .apiSaveSettings: return "apiSaveSettings"
case .apiGetSettings: return "apiGetSettings"
case .apiGetChatTags: return "apiGetChatTags"
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiGetChatItemInfo: return "apiGetChatItemInfo"
case .apiSendMessages: return "apiSendMessages"
case .apiCreateChatTag: return "apiCreateChatTag"
case .apiSetChatTags: return "apiSetChatTags"
case .apiDeleteChatTag: return "apiDeleteChatTag"
case .apiUpdateChatTag: return "apiUpdateChatTag"
case .apiReorderChatTags: return "apiReorderChatTags"
case .apiCreateChatItems: return "apiCreateChatItems"
case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem"
@ -564,6 +582,7 @@ public enum ChatResponse: Decodable, Error {
case chatSuspended
case apiChats(user: UserRef, chats: [ChatData])
case apiChat(user: UserRef, chat: ChatData)
case chatTags(user: UserRef, userTags: [ChatTag])
case chatItemInfo(user: UserRef, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case serverTestResult(user: UserRef, testServer: String, testFailure: ProtocolTestFailure?)
case serverOperatorConditions(conditions: ServerOperatorConditions)
@ -590,6 +609,7 @@ public enum ChatResponse: Decodable, Error {
case contactCode(user: UserRef, contact: Contact, connectionCode: String)
case groupMemberCode(user: UserRef, groupInfo: GroupInfo, member: GroupMember, connectionCode: String)
case connectionVerified(user: UserRef, verified: Bool, expectedCode: String)
case tagsUpdated(user: UserRef, userTags: [ChatTag], chatTags: [Int64])
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionUserChanged(user: UserRef, fromConnection: PendingContactConnection, toConnection: PendingContactConnection, newUser: UserRef)
@ -741,6 +761,7 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return "chatSuspended"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .chatTags: return "chatTags"
case .chatItemInfo: return "chatItemInfo"
case .serverTestResult: return "serverTestResult"
case .serverOperatorConditions: return "serverOperators"
@ -767,6 +788,7 @@ public enum ChatResponse: Decodable, Error {
case .contactCode: return "contactCode"
case .groupMemberCode: return "groupMemberCode"
case .connectionVerified: return "connectionVerified"
case .tagsUpdated: return "tagsUpdated"
case .invitation: return "invitation"
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
case .connectionUserChanged: return "connectionUserChanged"
@ -914,6 +936,7 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return noDetails
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .chatTags(u, userTags): return withUser(u, "userTags: \(String(describing: userTags))")
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
case let .serverOperatorConditions(conditions): return "conditions: \(String(describing: conditions))"
@ -942,6 +965,7 @@ public enum ChatResponse: Decodable, Error {
case let .contactCode(u, contact, connectionCode): return withUser(u, "contact: \(String(describing: contact))\nconnectionCode: \(connectionCode)")
case let .groupMemberCode(u, groupInfo, member, connectionCode): return withUser(u, "groupInfo: \(String(describing: groupInfo))\nmember: \(String(describing: member))\nconnectionCode: \(connectionCode)")
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
case let .tagsUpdated(u, userTags, chatTags): return withUser(u, "userTags: \(String(describing: userTags))\nchatTags: \(String(describing: chatTags))")
case let .invitation(u, connReqInvitation, connection): return withUser(u, "connReqInvitation: \(connReqInvitation)\nconnection: \(connection)")
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionUserChanged(u, fromConnection, toConnection, newUser): return withUser(u, "fromConnection: \(String(describing: fromConnection))\ntoConnection: \(String(describing: toConnection))\newUserId: \(String(describing: newUser.userId))")
@ -1172,6 +1196,16 @@ public enum ChatPagination {
}
}
public struct ChatTagData: Encodable {
public var emoji: String?
public var text: String
public init(emoji: String?, text: String) {
self.emoji = emoji
self.text = text
}
}
public struct ComposedMessage: Encodable {
public var fileSource: CryptoFile?
var quotedItemId: Int64?

View file

@ -1334,6 +1334,13 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
public var contactCard: Bool {
switch self {
case let .direct(contact): contact.activeConn == nil && contact.profile.contactLink != nil && contact.active
default: false
}
}
public var groupInfo: GroupInfo? {
switch self {
case let .group(groupInfo): return groupInfo
@ -1444,6 +1451,14 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
default: return nil
}
}
public var chatTags: [Int64]? {
switch self {
case let .direct(contact): return contact.chatTags
case let .group(groupInfo): return groupInfo.chatTags
default: return nil
}
}
var createdAt: Date {
switch self {
@ -1545,6 +1560,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
var chatTs: Date?
var contactGroupMemberId: Int64?
var contactGrpInvSent: Bool
public var chatTags: [Int64]
public var uiThemes: ThemeModeOverrides?
public var chatDeleted: Bool
@ -1615,6 +1631,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
createdAt: .now,
updatedAt: .now,
contactGrpInvSent: false,
chatTags: [],
chatDeleted: false
)
}
@ -1910,6 +1927,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
public var fullName: String { get { groupProfile.fullName } }
public var image: String? { get { groupProfile.image } }
public var localAlias: String { "" }
public var chatTags: [Int64]
public var isOwner: Bool {
return membership.memberRole == .owner && membership.memberCurrent
@ -1932,7 +1950,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
hostConnCustomUserProfileId: nil,
chatSettings: ChatSettings.defaults,
createdAt: .now,
updatedAt: .now
updatedAt: .now,
chatTags: []
)
}
@ -4210,6 +4229,20 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable {
}
}
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 {
public var itemVersions: [ChatItemVersion]
public var memberDeliveryStatuses: [MemberDeliveryStatus]?

View file

@ -155,6 +155,7 @@ library
Simplex.Chat.Migrations.M20241125_indexes
Simplex.Chat.Migrations.M20241128_business_chats
Simplex.Chat.Migrations.M20241205_business_chat_members
Simplex.Chat.Migrations.M20241206_chat_tags
Simplex.Chat.Mobile
Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared

View file

@ -847,6 +847,9 @@ processChatCommand' vr = \case
. sortOn (timeAvg . snd)
. M.assocs
<$> withConnection st (readTVarIO . DB.slow)
APIGetChatTags userId -> withUserId' userId $ \user -> do
tags <- withFastStore' (`getUserChatTags` user)
pure $ CRChatTags user tags
APIGetChats {userId, pendingConnections, pagination, query} -> withUserId' userId $ \user -> do
(errs, previews) <- partitionEithers <$> withFastStore' (\db -> getChatPreviews db vr user pendingConnections pagination query)
unless (null errs) $ toView $ CRChatErrors (Just user) (map ChatErrorStore errs)
@ -894,6 +897,26 @@ processChatCommand' vr = \case
CTLocal -> pure $ chatCmdError (Just user) "not supported"
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
CTContactConnection -> pure $ chatCmdError (Just user) "not supported"
APICreateChatTag (ChatTagData emoji text) -> withUser $ \user -> withFastStore' $ \db -> do
_ <- createChatTag db user emoji text
CRChatTags user <$> getUserChatTags db user
APISetChatTags (ChatRef cType chatId) tagIds -> withUser $ \user -> withFastStore' $ \db -> case cType of
CTDirect -> do
updateDirectChatTags db chatId (maybe [] L.toList tagIds)
CRTagsUpdated user <$> getUserChatTags db user <*> getDirectChatTags db chatId
CTGroup -> do
updateGroupChatTags db chatId (maybe [] L.toList tagIds)
CRTagsUpdated user <$> getUserChatTags db user <*> getGroupChatTags db chatId
_ -> pure $ chatCmdError (Just user) "not supported"
APIDeleteChatTag tagId -> withUser $ \user -> do
withFastStore' $ \db -> deleteChatTag db user tagId
ok user
APIUpdateChatTag tagId (ChatTagData emoji text) -> withUser $ \user -> do
withFastStore' $ \db -> updateChatTag db user tagId emoji text
ok user
APIReorderChatTags tagIds -> withUser $ \user -> do
withFastStore' $ \db -> reorderChatTags db user $ L.toList tagIds
ok user
APICreateChatItems folderId cms -> withUser $ \user ->
createNoteFolderContentItems user folderId (L.map (,Nothing) cms)
APIUpdateChatItem (ChatRef cType chatId) itemId live mc -> withUser $ \user -> case cType of
@ -8391,6 +8414,7 @@ chatCommandP =
"/sql chat " *> (ExecChatStoreSQL <$> textP),
"/sql agent " *> (ExecAgentStoreSQL <$> textP),
"/sql slow" $> SlowSQLQueries,
"/_get tags " *> (APIGetChatTags <$> A.decimal),
"/_get chats "
*> ( APIGetChats
<$> A.decimal
@ -8402,6 +8426,11 @@ chatCommandP =
"/_get items " *> (APIGetChatItems <$> chatPaginationP <*> optional (" search=" *> stringP)),
"/_get item info " *> (APIGetChatItemInfo <$> chatRefP <* A.space <*> A.decimal),
"/_send " *> (APISendMessages <$> chatRefP <*> liveMessageP <*> sendMessageTTLP <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)),
"/_create tag " *> (APICreateChatTag <$> jsonP),
"/_tags " *> (APISetChatTags <$> chatRefP <*> optional _strP),
"/_delete tag " *> (APIDeleteChatTag <$> A.decimal),
"/_update tag " *> (APIUpdateChatTag <$> A.decimal <* A.space <*> jsonP),
"/_reorder tags " *> (APIReorderChatTags <$> strP),
"/_create *" *> (APICreateChatItems <$> A.decimal <*> (" json " *> jsonP <|> " text " *> composedMessagesTextP)),
"/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP),
"/_delete item " *> (APIDeleteChatItem <$> chatRefP <*> _strP <* A.space <*> ciDeleteMode),

View file

@ -294,11 +294,17 @@ data ChatCommand
| ExecChatStoreSQL Text
| ExecAgentStoreSQL Text
| SlowSQLQueries
| APIGetChatTags UserId
| APIGetChats {userId :: UserId, pendingConnections :: Bool, pagination :: PaginationByTime, query :: ChatListQuery}
| APIGetChat ChatRef ChatPagination (Maybe String)
| APIGetChatItems ChatPagination (Maybe String)
| APIGetChatItemInfo ChatRef ChatItemId
| APISendMessages {chatRef :: ChatRef, liveMessage :: Bool, ttl :: Maybe Int, composedMessages :: NonEmpty ComposedMessage}
| APICreateChatTag ChatTagData
| APISetChatTags ChatRef (Maybe (NonEmpty ChatTagId))
| APIDeleteChatTag ChatTagId
| APIUpdateChatTag ChatTagId ChatTagData
| APIReorderChatTags (NonEmpty ChatTagId)
| APICreateChatItems {noteFolderId :: NoteFolderId, composedMessages :: NonEmpty ComposedMessage}
| APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent}
| APIDeleteChatItem ChatRef (NonEmpty ChatItemId) CIDeleteMode
@ -587,6 +593,7 @@ data ChatResponse
| CRApiChats {user :: User, chats :: [AChat]}
| CRChats {chats :: [AChat]}
| CRApiChat {user :: User, chat :: AChat, navInfo :: Maybe NavigationInfo}
| CRChatTags {user :: User, userTags :: [ChatTag]}
| CRChatItems {user :: User, chatName_ :: Maybe ChatName, chatItems :: [AChatItem]}
| CRChatItemInfo {user :: User, chatItem :: AChatItem, chatItemInfo :: ChatItemInfo}
| CRChatItemId User (Maybe ChatItemId)
@ -617,6 +624,7 @@ data ChatResponse
| CRContactCode {user :: User, contact :: Contact, connectionCode :: Text}
| CRGroupMemberCode {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionCode :: Text}
| CRConnectionVerified {user :: User, verified :: Bool, expectedCode :: Text}
| CRTagsUpdated {user :: User, userTags :: [ChatTag], chatTags :: [ChatTagId]}
| CRNewChatItems {user :: User, chatItems :: [AChatItem]}
| CRChatItemsStatusesUpdated {user :: User, chatItems :: [AChatItem]}
| CRChatItemUpdated {user :: User, chatItem :: AChatItem}
@ -1068,6 +1076,16 @@ instance FromJSON ComposedMessage where
parseJSON invalid =
JT.prependFailure "bad ComposedMessage, " (JT.typeMismatch "Object" invalid)
data ChatTagData = ChatTagData
{ emoji :: Maybe Text,
text :: Text
}
deriving (Show)
instance FromJSON ChatTagData where
parseJSON (J.Object v) = ChatTagData <$> v .:? "emoji" <*> v .: "text"
parseJSON invalid = JT.prependFailure "bad ChatTagData, " (JT.typeMismatch "Object" invalid)
data NtfConn = NtfConn
{ user_ :: Maybe User,
connEntity_ :: Maybe ConnectionEntity,
@ -1603,3 +1621,5 @@ $(JQ.deriveFromJSON defaultJSON ''ArchiveConfig)
$(JQ.deriveFromJSON defaultJSON ''DBEncryptionConfig)
$(JQ.deriveToJSON defaultJSON ''ComposedMessage)
$(JQ.deriveToJSON defaultJSON ''ChatTagData)

View file

@ -0,0 +1,47 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20241206_chat_tags where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20241206_chat_tags :: Query
m20241206_chat_tags =
[sql|
CREATE TABLE chat_tags (
chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users,
chat_tag_text TEXT NOT NULL,
chat_tag_emoji TEXT,
tag_order INTEGER NOT NULL
);
CREATE TABLE chat_tags_chats (
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE
);
CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(user_id, chat_tag_text);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(user_id, chat_tag_emoji);
CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(contact_id, chat_tag_id);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(group_id, chat_tag_id);
|]
down_m20241206_chat_tags :: Query
down_m20241206_chat_tags =
[sql|
DROP INDEX idx_chat_tags_user_id;
DROP INDEX idx_chat_tags_user_id_chat_tag_text;
DROP INDEX idx_chat_tags_user_id_chat_tag_emoji;
DROP INDEX idx_chat_tags_chats_chat_tag_id;
DROP INDEX idx_chat_tags_chats_chat_tag_id_contact_id;
DROP INDEX idx_chat_tags_chats_chat_tag_id_group_id;
DROP TABLE chat_tags_chats;
DROP TABLE chat_tags;
|]

View file

@ -623,6 +623,18 @@ CREATE TABLE operator_usage_conditions(
accepted_at TEXT,
created_at TEXT NOT NULL DEFAULT(datetime('now'))
);
CREATE TABLE chat_tags(
chat_tag_id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users,
chat_tag_text TEXT NOT NULL,
chat_tag_emoji TEXT,
tag_order INTEGER NOT NULL
);
CREATE TABLE chat_tags_chats(
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
chat_tag_id INTEGER NOT NULL REFERENCES chat_tags ON DELETE CASCADE
);
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@ -929,3 +941,21 @@ CREATE INDEX idx_chat_items_notes ON chat_items(
created_at
);
CREATE INDEX idx_groups_business_xcontact_id ON groups(business_xcontact_id);
CREATE INDEX idx_chat_tags_user_id ON chat_tags(user_id);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_text ON chat_tags(
user_id,
chat_tag_text
);
CREATE UNIQUE INDEX idx_chat_tags_user_id_chat_tag_emoji ON chat_tags(
user_id,
chat_tag_emoji
);
CREATE INDEX idx_chat_tags_chats_chat_tag_id ON chat_tags_chats(chat_tag_id);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_contact_id ON chat_tags_chats(
contact_id,
chat_tag_id
);
CREATE UNIQUE INDEX idx_chat_tags_chats_chat_tag_id_group_id ON chat_tags_chats(
group_id,
chat_tag_id
);

View file

@ -21,11 +21,14 @@ where
import Control.Applicative ((<|>))
import Control.Monad
import Control.Monad.Except
import Control.Monad.IO.Class
import Data.Bitraversable (bitraverse)
import Data.Int (Int64)
import Data.Maybe (catMaybes, fromMaybe)
import Database.SQLite.Simple (Only (..), (:.) (..))
import Database.SQLite.Simple.QQ (sql)
import Simplex.Chat.Protocol
import Simplex.Chat.Store.Direct
import Simplex.Chat.Store.Files
import Simplex.Chat.Store.Groups
import Simplex.Chat.Store.Profiles
@ -93,8 +96,9 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
(userId, agentConnId)
getContactRec_ :: Int64 -> Connection -> ExceptT StoreError IO Contact
getContactRec_ contactId c = ExceptT $ do
toContact' contactId c
<$> DB.query
chatTags <- getDirectChatTags db contactId
firstRow (toContact' contactId c chatTags) (SEInternalError "referenced contact not found") $
DB.query
db
[sql|
SELECT
@ -105,17 +109,16 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0
|]
(userId, contactId)
toContact' :: Int64 -> Connection -> [ContactRow'] -> Either StoreError Contact
toContact' contactId conn [(profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)] =
toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact
toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
activeConn = Just conn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData}
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember_ groupMemberId c = ExceptT $ do
firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $
getGroupAndMember_ groupMemberId c = do
gm <- ExceptT $ firstRow (toGroupAndMember c) (SEInternalError "referenced group member not found") $
DB.query
db
[sql|
@ -141,9 +144,10 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ?
|]
(groupMemberId, userId, userContactId)
liftIO $ bitraverse (addGroupChatTags db) pure gm
toGroupAndMember :: Connection -> GroupInfoRow :. GroupMemberRow -> (GroupInfo, GroupMember)
toGroupAndMember c (groupInfoRow :. memberRow) =
let groupInfo = toGroupInfo vr userContactId groupInfoRow
let groupInfo = toGroupInfo vr userContactId [] groupInfoRow
member = toGroupMember userContactId memberRow
in (groupInfo, (member :: GroupMember) {activeConn = Just c})
getConnSndFileTransfer_ :: Int64 -> Connection -> ExceptT StoreError IO SndFileTransfer

View file

@ -79,6 +79,8 @@ module Simplex.Chat.Store.Direct
setContactCustomData,
setContactUIThemes,
setContactChatDeleted,
getDirectChatTags,
updateDirectChatTags,
)
where
@ -180,8 +182,8 @@ getConnReqContactXContactId db vr user@User {userId} cReqHash = do
(userId, cReqHash)
getContactByConnReqHash :: DB.Connection -> VersionRangeChat -> User -> ConnReqUriHash -> IO (Maybe Contact)
getContactByConnReqHash db vr user@User {userId} cReqHash =
maybeFirstRow (toContact vr user) $
getContactByConnReqHash db vr user@User {userId} cReqHash = do
ct_ <- maybeFirstRow (toContact vr user []) $
DB.query
db
[sql|
@ -201,6 +203,7 @@ getContactByConnReqHash db vr user@User {userId} cReqHash =
LIMIT 1
|]
(userId, cReqHash, CSActive)
mapM (addDirectChatTags db) ct_
createDirectConnection :: DB.Connection -> User -> ConnId -> ConnReqInvitation -> ConnStatus -> Maybe Profile -> SubscriptionMode -> VersionChat -> PQSupport -> IO PendingContactConnection
createDirectConnection db User {userId} acId cReq pccConnStatus incognitoProfile subMode chatV pqSup = do
@ -251,6 +254,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p
chatTs = Just currentTs,
contactGroupMemberId = Nothing,
contactGrpInvSent = False,
chatTags = [],
uiThemes = Nothing,
chatDeleted = False,
customData = Nothing
@ -636,8 +640,8 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact
)
insertedRowId db
getContact' :: XContactId -> IO (Maybe Contact)
getContact' xContactId =
maybeFirstRow (toContact vr user) $
getContact' xContactId = do
ct_ <- maybeFirstRow (toContact vr user []) $
DB.query
db
[sql|
@ -657,13 +661,15 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact
LIMIT 1
|]
(userId, xContactId)
mapM (addDirectChatTags db) ct_
getGroupInfo' :: XContactId -> IO (Maybe GroupInfo)
getGroupInfo' xContactId =
maybeFirstRow (toGroupInfo vr userContactId) $
getGroupInfo' xContactId = do
g_ <- maybeFirstRow (toGroupInfo vr userContactId []) $
DB.query
db
(groupInfoQuery <> " WHERE g.business_xcontact_id = ? AND g.user_id = ? AND mu.contact_id = ?")
(xContactId, userId, userContactId)
mapM (addGroupChatTags db) g_
getContactRequestByXContactId :: XContactId -> IO (Maybe UserContactRequest)
getContactRequestByXContactId xContactId =
maybeFirstRow toContactRequest $
@ -819,6 +825,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
chatTs = Just createdAt,
contactGroupMemberId = Nothing,
contactGrpInvSent = False,
chatTags = [],
uiThemes = Nothing,
chatDeleted = False,
customData = Nothing
@ -845,8 +852,9 @@ getContact :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT Stor
getContact db vr user contactId = getContact_ db vr user contactId False
getContact_ :: DB.Connection -> VersionRangeChat -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact
getContact_ db vr user@User {userId} contactId deleted =
ExceptT . firstRow (toContact vr user) (SEContactNotFound contactId) $
getContact_ db vr user@User {userId} contactId deleted = do
chatTags <- liftIO $ getDirectChatTags db contactId
ExceptT . firstRow (toContact vr user chatTags) (SEContactNotFound contactId) $
DB.query
db
[sql|
@ -1018,3 +1026,39 @@ setContactChatDeleted :: DB.Connection -> User -> Contact -> Bool -> IO ()
setContactChatDeleted db User {userId} Contact {contactId} chatDeleted = do
updatedAt <- getCurrentTime
DB.execute db "UPDATE contacts SET chat_deleted = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (chatDeleted, updatedAt, userId, contactId)
updateDirectChatTags :: DB.Connection -> ContactId -> [ChatTagId] -> IO ()
updateDirectChatTags db contactId tIds = do
currentTags <- getDirectChatTags db contactId
let tagsToAdd = filter (`notElem` currentTags) tIds
tagsToDelete = filter (`notElem` tIds) currentTags
forM_ tagsToDelete $ untagDirectChat db contactId
forM_ tagsToAdd $ tagDirectChat db contactId
tagDirectChat :: DB.Connection -> ContactId -> ChatTagId -> IO ()
tagDirectChat db contactId tId =
DB.execute
db
[sql|
INSERT INTO chat_tags_chats (contact_id, chat_tag_id)
VALUES (?,?)
|]
(contactId, tId)
untagDirectChat :: DB.Connection -> ContactId -> ChatTagId -> IO ()
untagDirectChat db contactId tId =
DB.execute
db
[sql|
DELETE FROM chat_tags_chats
WHERE contact_id = ? AND chat_tag_id = ?
|]
(contactId, tId)
getDirectChatTags :: DB.Connection -> ContactId -> IO [ChatTagId]
getDirectChatTags db contactId = map fromOnly <$> DB.query db "SELECT chat_tag_id FROM chat_tags_chats WHERE contact_id = ?" (Only contactId)
addDirectChatTags :: DB.Connection -> Contact -> IO Contact
addDirectChatTags db ct = do
chatTags <- getDirectChatTags db $ contactId' ct
pure (ct :: Contact) {chatTags}

View file

@ -122,6 +122,8 @@ module Simplex.Chat.Store.Groups
updateUserMemberProfileSentAt,
setGroupCustomData,
setGroupUIThemes,
updateGroupChatTags,
getGroupChatTags,
)
where
@ -130,6 +132,7 @@ import Control.Monad.Except
import Control.Monad.IO.Class
import Crypto.Random (ChaChaDRG)
import Data.Bifunctor (second)
import Data.Bitraversable (bitraverse)
import Data.Either (rights)
import Data.Int (Int64)
import Data.List (partition, sortOn)
@ -249,8 +252,8 @@ setGroupLinkMemberRole db User {userId} userContactLinkId memberRole =
DB.execute db "UPDATE user_contact_links SET group_link_member_role = ? WHERE user_id = ? AND user_contact_link_id = ?" (memberRole, userId, userContactLinkId)
getGroupAndMember :: DB.Connection -> User -> Int64 -> VersionRangeChat -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember db User {userId, userContactId} groupMemberId vr =
ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $
getGroupAndMember db User {userId, userContactId} groupMemberId vr = do
gm <- ExceptT . firstRow toGroupAndMember (SEInternalError "referenced group member not found") $
DB.query
db
[sql|
@ -285,10 +288,11 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr =
WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ?
|]
(userId, groupMemberId, userId, userContactId)
liftIO $ bitraverse (addGroupChatTags db) pure gm
where
toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember)
toGroupAndMember (groupInfoRow :. memberRow :. connRow) =
let groupInfo = toGroupInfo vr userContactId groupInfoRow
let groupInfo = toGroupInfo vr userContactId [] groupInfoRow
member = toGroupMember userContactId memberRow
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow})
@ -333,6 +337,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc
updatedAt = currentTs,
chatTs = Just currentTs,
userMemberProfileSentAt = Just currentTs,
chatTags = [],
uiThemes = Nothing,
customData = Nothing
}
@ -401,6 +406,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ
updatedAt = currentTs,
chatTs = Just currentTs,
userMemberProfileSentAt = Just currentTs,
chatTags = [],
uiThemes = Nothing,
customData = Nothing
},
@ -624,8 +630,8 @@ getUserGroups db vr user@User {userId} = do
rights <$> mapM (runExceptT . getGroup db vr user) groupIds
getUserGroupDetails :: DB.Connection -> VersionRangeChat -> User -> Maybe ContactId -> Maybe String -> IO [GroupInfo]
getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ =
map (toGroupInfo vr userContactId)
getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do
g_ <- map (toGroupInfo vr userContactId [])
<$> DB.query
db
[sql|
@ -643,6 +649,7 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ =
AND (gp.display_name LIKE '%' || ? || '%' OR gp.full_name LIKE '%' || ? || '%' OR gp.description LIKE '%' || ? || '%')
|]
(userId, userContactId, search, search, search)
mapM (addGroupChatTags db) g_
where
search = fromMaybe "" search_
@ -1362,8 +1369,8 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange
createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff
getViaGroupMember :: DB.Connection -> VersionRangeChat -> User -> Contact -> IO (Maybe (GroupInfo, GroupMember))
getViaGroupMember db vr User {userId, userContactId} Contact {contactId} =
maybeFirstRow toGroupAndMember $
getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do
gm_ <- maybeFirstRow toGroupAndMember $
DB.query
db
[sql|
@ -1399,10 +1406,11 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} =
WHERE ct.user_id = ? AND ct.contact_id = ? AND mu.contact_id = ? AND ct.deleted = 0
|]
(userId, userId, contactId, userContactId)
mapM (bitraverse (addGroupChatTags db) pure) gm_
where
toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember)
toGroupAndMember (groupInfoRow :. memberRow :. connRow) =
let groupInfo = toGroupInfo vr userContactId groupInfoRow
let groupInfo = toGroupInfo vr userContactId [] groupInfoRow
member = toGroupMember userContactId memberRow
in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection vr connRow})
@ -1482,22 +1490,24 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName
updateGroupProfile db user g' p'
where
getGroupProfile =
ExceptT $ firstRow toGroupProfile (SEGroupNotFound groupId) $
DB.query
db
[sql|
ExceptT $
firstRow toGroupProfile (SEGroupNotFound groupId) $
DB.query
db
[sql|
SELECT gp.display_name, gp.full_name, gp.description, gp.image, gp.preferences
FROM group_profiles gp
JOIN groups g ON gp.group_profile_id = g.group_profile_id
WHERE g.group_id = ?
|]
(Only groupId)
(Only groupId)
toGroupProfile (displayName, fullName, description, image, groupPreferences) =
GroupProfile {displayName, fullName, description, image, groupPreferences}
getGroupInfo :: DB.Connection -> VersionRangeChat -> User -> Int64 -> ExceptT StoreError IO GroupInfo
getGroupInfo db vr User {userId, userContactId} groupId =
ExceptT . firstRow (toGroupInfo vr userContactId) (SEGroupNotFound groupId) $
getGroupInfo db vr User {userId, userContactId} groupId = ExceptT $ do
chatTags <- getGroupChatTags db groupId
firstRow (toGroupInfo vr userContactId chatTags) (SEGroupNotFound groupId) $
DB.query
db
(groupInfoQuery <> " WHERE g.group_id = ? AND g.user_id = ? AND mu.contact_id = ?")
@ -2053,7 +2063,7 @@ createMemberContact
quotaErrCounter = 0
}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, uiThemes = Nothing, chatDeleted = False, customData = Nothing}
pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing}
getMemberContact :: DB.Connection -> VersionRangeChat -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation)
getMemberContact db vr user contactId = do
@ -2090,7 +2100,7 @@ createMemberContactInvited
contactId <- createContactUpdateMember currentTs userPreferences
ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, uiThemes = Nothing, chatDeleted = False, customData = Nothing}
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False, chatTags = [], uiThemes = Nothing, chatDeleted = False, customData = Nothing}
m' = m {memberContactId = Just contactId}
pure (mCt', m')
where
@ -2301,3 +2311,31 @@ setGroupUIThemes :: DB.Connection -> User -> GroupInfo -> Maybe UIThemeEntityOve
setGroupUIThemes db User {userId} GroupInfo {groupId} uiThemes = do
updatedAt <- getCurrentTime
DB.execute db "UPDATE groups SET ui_themes = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (uiThemes, updatedAt, userId, groupId)
updateGroupChatTags :: DB.Connection -> GroupId -> [ChatTagId] -> IO ()
updateGroupChatTags db gId tIds = do
currentTags <- getGroupChatTags db gId
let tagsToAdd = filter (`notElem` currentTags) tIds
tagsToDelete = filter (`notElem` tIds) currentTags
forM_ tagsToDelete $ untagGroupChat db gId
forM_ tagsToAdd $ tagGroupChat db gId
tagGroupChat :: DB.Connection -> GroupId -> ChatTagId -> IO ()
tagGroupChat db groupId tId =
DB.execute
db
[sql|
INSERT INTO chat_tags_chats (group_id, chat_tag_id)
VALUES (?,?)
|]
(groupId, tId)
untagGroupChat :: DB.Connection -> GroupId -> ChatTagId -> IO ()
untagGroupChat db groupId tId =
DB.execute
db
[sql|
DELETE FROM chat_tags_chats
WHERE group_id = ? AND chat_tag_id = ?
|]
(groupId, tId)

View file

@ -119,6 +119,7 @@ import Simplex.Chat.Migrations.M20241027_server_operators
import Simplex.Chat.Migrations.M20241125_indexes
import Simplex.Chat.Migrations.M20241128_business_chats
import Simplex.Chat.Migrations.M20241205_business_chat_members
import Simplex.Chat.Migrations.M20241206_chat_tags
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@ -237,7 +238,8 @@ schemaMigrations =
("20241027_server_operators", m20241027_server_operators, Just down_m20241027_server_operators),
("20241125_indexes", m20241125_indexes, Just down_m20241125_indexes),
("20241128_business_chats", m20241128_business_chats, Just down_m20241128_business_chats),
("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members)
("20241205_business_chat_members", m20241205_business_chat_members, Just down_m20241205_business_chat_members),
("20241206_chat_tags", m20241206_chat_tags, Just down_m20241206_chat_tags)
]
-- | The list of migrations in ascending order by date

View file

@ -9,6 +9,7 @@
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeOperators #-}
{-# OPTIONS_GHC -fno-warn-ambiguous-fields #-}
module Simplex.Chat.Store.Shared where
@ -391,14 +392,14 @@ type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Mayb
type ContactRow = Only ContactId :. ContactRow'
toContact :: VersionRangeChat -> User -> ContactRow :. MaybeConnectionRow -> Contact
toContact vr user ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) =
toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact
toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData)) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
activeConn = toMaybeConnection vr connRow
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
incognito = maybe False connIncognito activeConn
mergedPreferences = contactUserPreferences user userPreferences preferences incognito
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, uiThemes, chatDeleted, customData}
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData}
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile
getProfileById db userId profileId =
@ -552,14 +553,14 @@ type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageD
type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Bool, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences))
toGroupInfo :: VersionRangeChat -> Int64 -> GroupInfoRow -> GroupInfo
toGroupInfo vr userContactId ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) =
toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) =
let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr}
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
fullGroupPreferences = mergeGroupPreferences groupPreferences
groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences}
businessChat = toBusinessChatInfo businessRow
in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, uiThemes, customData}
in GroupInfo {groupId, localDisplayName, groupProfile, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, uiThemes, customData}
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) =
@ -592,3 +593,76 @@ groupInfoQuery =
JOIN group_members mu ON mu.group_id = g.group_id
JOIN contact_profiles pu ON pu.contact_profile_id = COALESCE(mu.member_profile_id, mu.contact_profile_id)
|]
createChatTag :: DB.Connection -> User -> Maybe Text -> Text -> IO ChatTagId
createChatTag db User {userId} emoji text = do
DB.execute
db
[sql|
INSERT INTO chat_tags (user_id, chat_tag_emoji, chat_tag_text, tag_order)
VALUES (?,?,?, COALESCE((SELECT MAX(tag_order) + 1 FROM chat_tags WHERE user_id = ?), 1))
|]
(userId, emoji, text, userId)
insertedRowId db
deleteChatTag :: DB.Connection -> User -> ChatTagId -> IO ()
deleteChatTag db User {userId} tId =
DB.execute
db
[sql|
DELETE FROM chat_tags
WHERE user_id = ? AND chat_tag_id = ?
|]
(userId, tId)
updateChatTag :: DB.Connection -> User -> ChatTagId -> Maybe Text -> Text -> IO ()
updateChatTag db User {userId} tId emoji text =
DB.execute
db
[sql|
UPDATE chat_tags
SET chat_tag_emoji = ?, chat_tag_text = ?
WHERE user_id = ? AND chat_tag_id = ?
|]
(emoji, text, userId, tId)
updateChatTagOrder :: DB.Connection -> User -> ChatTagId -> Int -> IO ()
updateChatTagOrder db User {userId} tId order =
DB.execute
db
[sql|
UPDATE chat_tags
SET tag_order = ?
WHERE user_id = ? AND chat_tag_id = ?
|]
(order, userId, tId)
reorderChatTags :: DB.Connection -> User -> [ChatTagId] -> IO ()
reorderChatTags db user tIds =
forM_ (zip [1 ..] tIds) $ \(order, tId) ->
updateChatTagOrder db user tId order
getUserChatTags :: DB.Connection -> User -> IO [ChatTag]
getUserChatTags db User {userId} =
map toChatTag
<$> DB.query
db
[sql|
SELECT chat_tag_id, chat_tag_emoji, chat_tag_text
FROM chat_tags
WHERE user_id = ?
ORDER BY tag_order
|]
(Only userId)
where
toChatTag :: (ChatTagId, Maybe Text, Text) -> ChatTag
toChatTag (chatTagId, chatTagEmoji, chatTagText) = ChatTag {chatTagId, chatTagEmoji, chatTagText}
getGroupChatTags :: DB.Connection -> GroupId -> IO [ChatTagId]
getGroupChatTags db groupId =
map fromOnly <$> DB.query db "SELECT chat_tag_id FROM chat_tags_chats WHERE group_id = ?" (Only groupId)
addGroupChatTags :: DB.Connection -> GroupInfo -> IO GroupInfo
addGroupChatTags db g@GroupInfo {groupId} = do
chatTags <- getGroupChatTags db groupId
pure (g :: GroupInfo) {chatTags}

View file

@ -160,6 +160,8 @@ type ContactId = Int64
type ProfileId = Int64
type ChatTagId = Int64
data Contact = Contact
{ contactId :: ContactId,
localDisplayName :: ContactName,
@ -176,6 +178,7 @@ data Contact = Contact
chatTs :: Maybe UTCTime,
contactGroupMemberId :: Maybe GroupMemberId,
contactGrpInvSent :: Bool,
chatTags :: [ChatTagId],
uiThemes :: Maybe UIThemeEntityOverrides,
chatDeleted :: Bool,
customData :: Maybe CustomData
@ -380,6 +383,7 @@ data GroupInfo = GroupInfo
updatedAt :: UTCTime,
chatTs :: Maybe UTCTime,
userMemberProfileSentAt :: Maybe UTCTime,
chatTags :: [ChatTagId],
uiThemes :: Maybe UIThemeEntityOverrides,
customData :: Maybe CustomData
}
@ -1637,6 +1641,13 @@ data CommandData = CommandData
}
deriving (Show)
data ChatTag = ChatTag
{ chatTagId :: Int64,
chatTagText :: Text,
chatTagEmoji :: Maybe Text
}
deriving (Show)
-- ad-hoc type for data required for XGrpMemIntro continuation
data XGrpMemIntroCont = XGrpMemIntroCont
{ groupId :: GroupId,
@ -1791,3 +1802,5 @@ $(JQ.deriveJSON defaultJSON ''Contact)
$(JQ.deriveJSON defaultJSON ''ContactRef)
$(JQ.deriveJSON defaultJSON ''NoteFolder)
$(JQ.deriveJSON defaultJSON ''ChatTag)

View file

@ -96,6 +96,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
CRApiChats u chats -> ttyUser u $ if testView then testViewChats chats else [viewJSON chats]
CRChats chats -> viewChats ts tz chats
CRApiChat u chat _ -> ttyUser u $ if testView then testViewChat chat else [viewJSON chat]
CRChatTags u tags -> ttyUser u $ [viewJSON tags]
CRApiParsedMarkdown ft -> [viewJSON ft]
CRServerTestResult u srv testFailure -> ttyUser u $ viewServerTestResult srv testFailure
CRServerOperatorConditions (ServerOperatorConditions ops _ ca) -> viewServerOperators ops ca
@ -149,6 +150,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
| otherwise -> []
CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted u chat item $ viewItemUpdate chat item liveItems ts tz
CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci
CRTagsUpdated u _ _ -> ttyUser u ["chat tags updated"]
CRChatItemsDeleted u deletions byUser timed -> case deletions of
[ChatItemDeletion (AChatItem _ _ chat deletedItem) toItem] ->
ttyUser u $ unmuted u chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts tz testView