mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-14 21:15:37 +00:00
core, ui: support chat item TTL per chat and group aliases (#5415)
* core: support chat item TTL per chat * ios: UI mockup * core: chat time to live and group local alias support (#5533) * functions and type placeholders * simplify * queries to make tests pass * set chat queries * fetch queries * get local aliases for groups * local alias support for groups * simplify * fix tests * fix --------- Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com> * migration * add test for expiration * expireChatItems * refactor queries, read objects inside the loop * add groupId to query * fix updateGroupAlias * ios group alias * ttl * changes * fixes and test * new types for ttl * chat and groups ttl in ios * accurate alert * label * progress indicator, disable interactions while api running * just call expire chat items * android, desktop: add local alias to groups (#5544) * android, desktop: add local alias to groups * different placeholder for chats vs contacts * improvements and fixes * only expire chat items, not all items, when chat ttl changes * refactor, fix conditions * refactor * refactor ChatTTLOption * text * fix * make ttl state * fix crash/remove warnings * fix for current? --------- Co-authored-by: Diogo <diogofncunha@gmail.com>
This commit is contained in:
@@ -340,7 +340,7 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
||||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
@@ -353,6 +353,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
await MainActor.run {
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
if (replaceChat) {
|
||||
m.replaceChat(chat.chatInfo.id, chat)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
@@ -644,7 +647,13 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL {
|
||||
}
|
||||
|
||||
private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL {
|
||||
if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||
if case let .chatItemTTL(_, chatItemTTL) = r {
|
||||
if let ttl = chatItemTTL {
|
||||
return ChatItemTTL(ttl)
|
||||
} else {
|
||||
throw RuntimeError("chatItemTTLResponse: invalid ttl")
|
||||
}
|
||||
}
|
||||
throw r
|
||||
}
|
||||
|
||||
@@ -653,6 +662,11 @@ func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
|
||||
try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds))
|
||||
}
|
||||
|
||||
func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async throws {
|
||||
let userId = try currentUserId("setChatItemTTL")
|
||||
try await sendCommandOkResp(.apiSetChatTTL(userId: userId, type: chatType, id: id, seconds: chatItemTTL.value))
|
||||
}
|
||||
|
||||
func getNetworkConfig() async throws -> NetCfg? {
|
||||
let r = await chatSendCmd(.apiGetNetworkConfig)
|
||||
if case let .networkConfig(cfg) = r { return cfg }
|
||||
@@ -1044,6 +1058,12 @@ func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Co
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? {
|
||||
let r = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias))
|
||||
if case let .groupAliasUpdated(_, toGroup) = r { return toGroup }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? {
|
||||
let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias))
|
||||
if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection }
|
||||
|
||||
@@ -109,6 +109,7 @@ struct ChatInfoView: View {
|
||||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@State private var progressIndicator = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
@@ -137,50 +138,48 @@ struct ChatInfoView: View {
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
contactInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
Group {
|
||||
ZStack {
|
||||
List {
|
||||
contactInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
localAliasTextEdit()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
GeometryReader { g in
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let buttonWidth = g.size.width / 4
|
||||
searchButton(width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
muteButton(width: buttonWidth)
|
||||
}
|
||||
}
|
||||
.padding(.trailing)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
GeometryReader { g in
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let buttonWidth = g.size.width / 4
|
||||
searchButton(width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
muteButton(width: buttonWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Group {
|
||||
.padding(.trailing)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
@@ -191,97 +190,109 @@ struct ChatInfoView: View {
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
|
||||
Section {
|
||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("Delete chat messages from your device.")
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
Section {
|
||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
.disabled(progressIndicator)
|
||||
.opacity(progressIndicator ? 0.6 : 1)
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear {
|
||||
@@ -290,7 +301,6 @@ struct ChatInfoView: View {
|
||||
}
|
||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||
@@ -498,7 +508,7 @@ struct ChatInfoView: View {
|
||||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
Task {
|
||||
@@ -643,6 +653,63 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatTTLOption: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var progressIndicator: Bool
|
||||
@State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0))
|
||||
@State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0))
|
||||
|
||||
var body: some View {
|
||||
Picker("Delete messages after", selection: $chatItemTTL) {
|
||||
ForEach(ChatItemTTL.values) { ttl in
|
||||
Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl))
|
||||
}
|
||||
let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL)
|
||||
Text(defaultTTL.text).tag(defaultTTL)
|
||||
|
||||
if case .chat(let ttl) = chatItemTTL, case .seconds = ttl {
|
||||
Text(ttl.deleteAfterText).tag(chatItemTTL)
|
||||
}
|
||||
}
|
||||
.disabled(progressIndicator)
|
||||
.frame(height: 36)
|
||||
.onChange(of: chatItemTTL) { ttl in
|
||||
if ttl == currentChatItemTTL { return }
|
||||
setChatTTL(
|
||||
ttl,
|
||||
hasPreviousTTL: !currentChatItemTTL.neverExpires,
|
||||
onCancel: { chatItemTTL = currentChatItemTTL }
|
||||
) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
|
||||
await loadChat(chat: chat, clearItems: true, replaceChat: true)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
currentChatItemTTL = chatItemTTL
|
||||
}
|
||||
}
|
||||
catch let error {
|
||||
logger.error("setChatTTL error \(responseError(error))")
|
||||
await loadChat(chat: chat, clearItems: true, replaceChat: true)
|
||||
await MainActor.run {
|
||||
chatItemTTL = currentChatItemTTL
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let sm = ChatModel.shared
|
||||
let ttl = chat.chatInfo.ttl(sm.chatItemTTL)
|
||||
chatItemTTL = ttl
|
||||
currentChatItemTTL = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
@@ -1054,6 +1121,33 @@ func deleteContactDialog(
|
||||
}
|
||||
}
|
||||
|
||||
func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
|
||||
let title = if ttl.neverExpires {
|
||||
NSLocalizedString("Disable automatic message deletion?", comment: "alert title")
|
||||
} else if ttl.usingDefault || hasPreviousTTL {
|
||||
NSLocalizedString("Change automatic message deletion?", comment: "alert title")
|
||||
} else {
|
||||
NSLocalizedString("Enable automatic message deletion?", comment: "alert title")
|
||||
}
|
||||
|
||||
let message = if ttl.neverExpires {
|
||||
NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message")
|
||||
} else {
|
||||
NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message")
|
||||
}
|
||||
|
||||
showAlert(title, message: message) {
|
||||
[
|
||||
UIAlertAction(
|
||||
title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"),
|
||||
style: .destructive,
|
||||
handler: { _ in onConfirm() }
|
||||
),
|
||||
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() })
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContactOrConversationDialog(
|
||||
_ chat: Chat,
|
||||
_ contact: Contact,
|
||||
@@ -1254,7 +1348,7 @@ struct ChatInfoView_Previews: PreviewProvider {
|
||||
localAlias: "",
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
onSearch: {}
|
||||
onSearch: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,7 +253,8 @@ struct ChatView: View {
|
||||
chat.created = Date.now
|
||||
}
|
||||
),
|
||||
onSearch: { focusSearch() }
|
||||
onSearch: { focusSearch() },
|
||||
localAlias: groupInfo.localAlias
|
||||
)
|
||||
}
|
||||
} else if case .local = cInfo {
|
||||
|
||||
@@ -18,6 +18,8 @@ struct GroupChatInfoView: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var groupInfo: GroupInfo
|
||||
var onSearch: () -> Void
|
||||
@State var localAlias: String
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
@@ -27,6 +29,7 @@ struct GroupChatInfoView: View {
|
||||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@State private var progressIndicator = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
@@ -67,101 +70,120 @@ struct GroupChatInfoView: View {
|
||||
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
|
||||
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
|
||||
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
infoActionButtons()
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editGroupButton()
|
||||
}
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
ZStack {
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
localAliasTextEdit()
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
infoActionButtons()
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editGroupButton()
|
||||
}
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { alert = .cantInviteIncognitoAlert }
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
addMembersButton()
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
if members.count > 8 {
|
||||
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||
.padding(.leading, 8)
|
||||
|
||||
Section {
|
||||
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
|
||||
} footer: {
|
||||
Text("Delete chat messages from your device.")
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member)
|
||||
} label: {
|
||||
EmptyView()
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
}
|
||||
.opacity(0)
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { alert = .cantInviteIncognitoAlert }
|
||||
} else {
|
||||
addMembersButton()
|
||||
}
|
||||
}
|
||||
if members.count > 8 {
|
||||
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
}
|
||||
if groupInfo.membership.memberCurrent {
|
||||
leaveGroupButton()
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
}
|
||||
if groupInfo.membership.memberCurrent {
|
||||
leaveGroupButton()
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
.disabled(progressIndicator)
|
||||
.opacity(progressIndicator ? 0.6 : 1)
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
@@ -200,7 +222,7 @@ struct GroupChatInfoView: View {
|
||||
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
Text(cInfo.displayName)
|
||||
Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
@@ -215,6 +237,37 @@ struct GroupChatInfoView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private func localAliasTextEdit() -> some View {
|
||||
TextField("Set chat name…", text: $localAlias)
|
||||
.disableAutocorrection(true)
|
||||
.focused($aliasTextFieldFocused)
|
||||
.submitLabel(.done)
|
||||
.onChange(of: aliasTextFieldFocused) { focused in
|
||||
if !focused {
|
||||
setGroupAlias()
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
setGroupAlias()
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
private func setGroupAlias() {
|
||||
Task {
|
||||
do {
|
||||
if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) {
|
||||
await MainActor.run {
|
||||
chatModel.updateGroup(gInfo)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("setGroupAlias error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func infoActionButtons() -> some View {
|
||||
GeometryReader { g in
|
||||
let buttonWidth = g.size.width / 4
|
||||
@@ -739,7 +792,8 @@ struct GroupChatInfoView_Previews: PreviewProvider {
|
||||
GroupChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
onSearch: {}
|
||||
onSearch: {},
|
||||
localAlias: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,8 +89,9 @@ public enum ChatCommand {
|
||||
case apiGetUsageConditions
|
||||
case apiSetConditionsNotified(conditionsId: Int64)
|
||||
case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64])
|
||||
case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
|
||||
case apiSetChatItemTTL(userId: Int64, seconds: Int64)
|
||||
case apiGetChatItemTTL(userId: Int64)
|
||||
case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?)
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
case apiGetNetworkConfig
|
||||
case apiSetNetworkInfo(networkInfo: UserNetworkInfo)
|
||||
@@ -124,6 +125,7 @@ public enum ChatCommand {
|
||||
case apiUpdateProfile(userId: Int64, profile: Profile)
|
||||
case apiSetContactPrefs(contactId: Int64, preferences: Preferences)
|
||||
case apiSetContactAlias(contactId: Int64, localAlias: String)
|
||||
case apiSetGroupAlias(groupId: Int64, localAlias: String)
|
||||
case apiSetConnectionAlias(connId: Int64, localAlias: String)
|
||||
case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?)
|
||||
case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?)
|
||||
@@ -265,6 +267,7 @@ public enum ChatCommand {
|
||||
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
|
||||
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
|
||||
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
|
||||
case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
case .apiGetNetworkConfig: return "/network"
|
||||
case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))"
|
||||
@@ -308,6 +311,7 @@ public enum ChatCommand {
|
||||
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
|
||||
case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
|
||||
case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")"
|
||||
case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")"
|
||||
@@ -434,6 +438,7 @@ public enum ChatCommand {
|
||||
case .apiAcceptConditions: return "apiAcceptConditions"
|
||||
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
||||
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
||||
case .apiSetChatTTL: return "apiSetChatTTL"
|
||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||
case .apiGetNetworkConfig: return "apiGetNetworkConfig"
|
||||
case .apiSetNetworkInfo: return "apiSetNetworkInfo"
|
||||
@@ -466,6 +471,7 @@ public enum ChatCommand {
|
||||
case .apiUpdateProfile: return "apiUpdateProfile"
|
||||
case .apiSetContactPrefs: return "apiSetContactPrefs"
|
||||
case .apiSetContactAlias: return "apiSetContactAlias"
|
||||
case .apiSetGroupAlias: return "apiSetGroupAlias"
|
||||
case .apiSetConnectionAlias: return "apiSetConnectionAlias"
|
||||
case .apiSetUserUIThemes: return "apiSetUserUIThemes"
|
||||
case .apiSetChatUIThemes: return "apiSetChatUIThemes"
|
||||
@@ -523,7 +529,7 @@ public enum ChatCommand {
|
||||
if let seconds = seconds {
|
||||
return String(seconds)
|
||||
} else {
|
||||
return "none"
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,6 +635,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
|
||||
case userPrivacy(user: User, updatedUser: User)
|
||||
case contactAliasUpdated(user: UserRef, toContact: Contact)
|
||||
case groupAliasUpdated(user: UserRef, toGroup: GroupInfo)
|
||||
case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection)
|
||||
case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact)
|
||||
case userContactLink(user: User, contactLink: UserContactLink)
|
||||
@@ -809,6 +816,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .userProfileUpdated: return "userProfileUpdated"
|
||||
case .userPrivacy: return "userPrivacy"
|
||||
case .contactAliasUpdated: return "contactAliasUpdated"
|
||||
case .groupAliasUpdated: return "groupAliasUpdated"
|
||||
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
||||
case .contactPrefsUpdated: return "contactPrefsUpdated"
|
||||
case .userContactLink: return "userContactLink"
|
||||
@@ -987,6 +995,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
|
||||
case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser))
|
||||
case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact))
|
||||
case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
|
||||
case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
|
||||
case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))")
|
||||
case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails)
|
||||
|
||||
@@ -1500,6 +1500,24 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
case .invalidJSON: return .now
|
||||
}
|
||||
}
|
||||
|
||||
public func ttl(_ globalTTL: ChatItemTTL) -> ChatTTL {
|
||||
switch self {
|
||||
case let .direct(contact):
|
||||
return if let ciTTL = contact.chatItemTTL {
|
||||
ChatTTL.chat(ChatItemTTL(ciTTL))
|
||||
} else {
|
||||
ChatTTL.userDefault(globalTTL)
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
return if let ciTTL = groupInfo.chatItemTTL {
|
||||
ChatTTL.chat(ChatItemTTL(ciTTL))
|
||||
} else {
|
||||
ChatTTL.userDefault(globalTTL)
|
||||
}
|
||||
default: return ChatTTL.userDefault(globalTTL)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SampleData: Hashable {
|
||||
public var direct: ChatInfo
|
||||
@@ -1572,6 +1590,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
|
||||
var contactGroupMemberId: Int64?
|
||||
var contactGrpInvSent: Bool
|
||||
public var chatTags: [Int64]
|
||||
public var chatItemTTL: Int64?
|
||||
public var uiThemes: ThemeModeOverrides?
|
||||
public var chatDeleted: Bool
|
||||
|
||||
@@ -1930,11 +1949,12 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
public var apiId: Int64 { get { groupId } }
|
||||
public var ready: Bool { get { true } }
|
||||
public var sendMsgEnabled: Bool { get { membership.memberActive } }
|
||||
public var displayName: String { get { groupProfile.displayName } }
|
||||
public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias }
|
||||
public var fullName: String { get { groupProfile.fullName } }
|
||||
public var image: String? { get { groupProfile.image } }
|
||||
public var localAlias: String { "" }
|
||||
public var chatTags: [Int64]
|
||||
public var chatItemTTL: Int64?
|
||||
public var localAlias: String
|
||||
|
||||
public var isOwner: Bool {
|
||||
return membership.memberRole == .owner && membership.memberCurrent
|
||||
@@ -1958,7 +1978,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
|
||||
chatSettings: ChatSettings.defaults,
|
||||
createdAt: .now,
|
||||
updatedAt: .now,
|
||||
chatTags: []
|
||||
chatTags: [],
|
||||
localAlias: ""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4334,45 +4355,53 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable {
|
||||
case day
|
||||
case week
|
||||
case month
|
||||
case year
|
||||
case seconds(_ seconds: Int64)
|
||||
case none
|
||||
|
||||
public static var values: [ChatItemTTL] { [.none, .month, .week, .day] }
|
||||
public static var values: [ChatItemTTL] { [.none, .year, .month, .week, .day] }
|
||||
|
||||
public var id: Self { self }
|
||||
|
||||
public init(_ seconds: Int64?) {
|
||||
public init(_ seconds: Int64) {
|
||||
switch seconds {
|
||||
case 0: self = .none
|
||||
case 86400: self = .day
|
||||
case 7 * 86400: self = .week
|
||||
case 30 * 86400: self = .month
|
||||
case let .some(n): self = .seconds(n)
|
||||
case .none: self = .none
|
||||
case 365 * 86400: self = .year
|
||||
default: self = .seconds(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
public var deleteAfterText: LocalizedStringKey {
|
||||
public var deleteAfterText: String {
|
||||
switch self {
|
||||
case .day: return "1 day"
|
||||
case .week: return "1 week"
|
||||
case .month: return "1 month"
|
||||
case let .seconds(seconds): return "\(seconds) second(s)"
|
||||
case .none: return "never"
|
||||
case .day: return NSLocalizedString("1 day", comment: "delete after time")
|
||||
case .week: return NSLocalizedString("1 week", comment: "delete after time")
|
||||
case .month: return NSLocalizedString("1 month", comment: "delete after time")
|
||||
case .year: return NSLocalizedString("1 year", comment: "delete after time")
|
||||
case let .seconds(seconds): return String.localizedStringWithFormat(NSLocalizedString("%d seconds(s)", comment: "delete after time"), seconds)
|
||||
case .none: return NSLocalizedString("never", comment: "delete after time")
|
||||
}
|
||||
}
|
||||
|
||||
public var seconds: Int64? {
|
||||
public var seconds: Int64 {
|
||||
switch self {
|
||||
case .day: return 86400
|
||||
case .week: return 7 * 86400
|
||||
case .month: return 30 * 86400
|
||||
case .year: return 365 * 86400
|
||||
case let .seconds(seconds): return seconds
|
||||
case .none: return nil
|
||||
case .none: return 0
|
||||
}
|
||||
}
|
||||
|
||||
private var comparisonValue: Int64 {
|
||||
self.seconds ?? Int64.max
|
||||
if self.seconds == 0 {
|
||||
return Int64.max
|
||||
} else {
|
||||
return self.seconds
|
||||
}
|
||||
}
|
||||
|
||||
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
@@ -4380,6 +4409,43 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public enum ChatTTL: Identifiable, Hashable {
|
||||
case userDefault(ChatItemTTL)
|
||||
case chat(ChatItemTTL)
|
||||
|
||||
public var id: Self { self }
|
||||
|
||||
public var text: String {
|
||||
switch self {
|
||||
case let .chat(ttl): return ttl.deleteAfterText
|
||||
case let .userDefault(ttl): return String.localizedStringWithFormat(
|
||||
NSLocalizedString("default (%@)", comment: "delete after time"),
|
||||
ttl.deleteAfterText)
|
||||
}
|
||||
}
|
||||
|
||||
public var neverExpires: Bool {
|
||||
switch self {
|
||||
case let .chat(ttl): return ttl.seconds == 0
|
||||
case let .userDefault(ttl): return ttl.seconds == 0
|
||||
}
|
||||
}
|
||||
|
||||
public var value: Int64? {
|
||||
switch self {
|
||||
case let .chat(ttl): return ttl.seconds
|
||||
case .userDefault: return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var usingDefault: Bool {
|
||||
switch self {
|
||||
case .userDefault: return true
|
||||
case .chat: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatTag: Decodable, Hashable {
|
||||
public var chatTagId: Int64
|
||||
public var chatTagText: String
|
||||
|
||||
+5
-4
@@ -1725,7 +1725,8 @@ data class GroupInfo (
|
||||
override val updatedAt: Instant,
|
||||
val chatTs: Instant?,
|
||||
val uiThemes: ThemeModeOverrides? = null,
|
||||
val chatTags: List<Long>
|
||||
val chatTags: List<Long>,
|
||||
override val localAlias: String,
|
||||
): SomeChat, NamedChat {
|
||||
override val chatType get() = ChatType.Group
|
||||
override val id get() = "#$groupId"
|
||||
@@ -1743,10 +1744,9 @@ data class GroupInfo (
|
||||
ChatFeature.Calls -> false
|
||||
}
|
||||
override val timedMessagesTTL: Int? get() = with(fullGroupPreferences.timedMessages) { if (on) ttl else null }
|
||||
override val displayName get() = groupProfile.displayName
|
||||
override val displayName get() = localAlias.ifEmpty { groupProfile.displayName }
|
||||
override val fullName get() = groupProfile.fullName
|
||||
override val image get() = groupProfile.image
|
||||
override val localAlias get() = ""
|
||||
|
||||
val isOwner: Boolean
|
||||
get() = membership.memberRole == GroupMemberRole.Owner && membership.memberCurrent
|
||||
@@ -1773,7 +1773,8 @@ data class GroupInfo (
|
||||
updatedAt = Clock.System.now(),
|
||||
chatTs = Clock.System.now(),
|
||||
uiThemes = null,
|
||||
chatTags = emptyList()
|
||||
chatTags = emptyList(),
|
||||
localAlias = ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -1562,6 +1562,13 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetGroupAlias(rh: Long?, groupId: Long, localAlias: String): GroupInfo? {
|
||||
val r = sendCmd(rh, CC.ApiSetGroupAlias(groupId, localAlias))
|
||||
if (r is CR.GroupAliasUpdated) return r.toGroup
|
||||
Log.e(TAG, "apiSetGroupAlias bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? {
|
||||
val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias))
|
||||
if (r is CR.ConnectionAliasUpdated) return r.toConnection
|
||||
@@ -3411,6 +3418,7 @@ sealed class CC {
|
||||
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
|
||||
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
|
||||
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
|
||||
class ApiSetGroupAlias(val groupId: Long, val localAlias: String): CC()
|
||||
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
|
||||
class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC()
|
||||
class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC()
|
||||
@@ -3592,6 +3600,7 @@ sealed class CC {
|
||||
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
|
||||
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
|
||||
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
|
||||
is ApiSetGroupAlias -> "/_set alias #$groupId ${localAlias.trim()}"
|
||||
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
|
||||
is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}"
|
||||
is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}"
|
||||
@@ -3751,6 +3760,7 @@ sealed class CC {
|
||||
is ApiUpdateProfile -> "apiUpdateProfile"
|
||||
is ApiSetContactPrefs -> "apiSetContactPrefs"
|
||||
is ApiSetContactAlias -> "apiSetContactAlias"
|
||||
is ApiSetGroupAlias -> "apiSetGroupAlias"
|
||||
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
|
||||
is ApiSetUserUIThemes -> "apiSetUserUIThemes"
|
||||
is ApiSetChatUIThemes -> "apiSetChatUIThemes"
|
||||
@@ -5645,6 +5655,7 @@ sealed class CR {
|
||||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
|
||||
@Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR()
|
||||
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: UserRef, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("groupAliasUpdated") class GroupAliasUpdated(val user: UserRef, val toGroup: GroupInfo): CR()
|
||||
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR()
|
||||
@@ -5832,6 +5843,7 @@ sealed class CR {
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
is UserPrivacy -> "userPrivacy"
|
||||
is ContactAliasUpdated -> "contactAliasUpdated"
|
||||
is GroupAliasUpdated -> "groupAliasUpdated"
|
||||
is ConnectionAliasUpdated -> "connectionAliasUpdated"
|
||||
is ContactPrefsUpdated -> "contactPrefsUpdated"
|
||||
is UserContactLink -> "userContactLink"
|
||||
@@ -6009,6 +6021,7 @@ sealed class CR {
|
||||
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
|
||||
is UserPrivacy -> withUser(user, json.encodeToString(updatedUser))
|
||||
is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
|
||||
is GroupAliasUpdated -> withUser(user, json.encodeToString(toGroup))
|
||||
is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
|
||||
is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")
|
||||
is UserContactLink -> withUser(user, contactLink.responseDetails)
|
||||
|
||||
+2
-1
@@ -732,6 +732,7 @@ fun LocalAliasEditor(
|
||||
center: Boolean = true,
|
||||
leadingIcon: Boolean = false,
|
||||
focus: Boolean = false,
|
||||
isContact: Boolean = true,
|
||||
updateValue: (String) -> Unit
|
||||
) {
|
||||
val state = remember(chatId) {
|
||||
@@ -748,7 +749,7 @@ fun LocalAliasEditor(
|
||||
state,
|
||||
{
|
||||
Text(
|
||||
generalGetString(MR.strings.text_field_set_contact_placeholder),
|
||||
generalGetString(if (isContact) MR.strings.text_field_set_contact_placeholder else MR.strings.text_field_set_chat_placeholder),
|
||||
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
|
||||
+20
-5
@@ -70,6 +70,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
|
||||
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
|
||||
.sortedByDescending { it.memberRole },
|
||||
developerTools,
|
||||
onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) },
|
||||
groupLink,
|
||||
scrollToItemId,
|
||||
addMembers = {
|
||||
@@ -286,6 +287,7 @@ fun ModalData.GroupChatInfoLayout(
|
||||
setSendReceipts: (SendReceipts) -> Unit,
|
||||
members: List<GroupMember>,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
groupLink: String?,
|
||||
scrollToItemId: MutableState<Long?>,
|
||||
addMembers: () -> Unit,
|
||||
@@ -327,8 +329,11 @@ fun ModalData.GroupChatInfoLayout(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupChatInfoHeader(chat.chatInfo)
|
||||
GroupChatInfoHeader(chat.chatInfo, groupInfo)
|
||||
}
|
||||
|
||||
LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged)
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
Box(
|
||||
@@ -459,7 +464,7 @@ fun ModalData.GroupChatInfoLayout(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
@@ -467,18 +472,18 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val copyNameToClipboard = {
|
||||
clipboard.setText(AnnotatedString(cInfo.displayName))
|
||||
clipboard.setText(AnnotatedString(groupInfo.groupProfile.displayName))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
groupInfo.groupProfile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != groupInfo.groupProfile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
@@ -742,6 +747,15 @@ private fun SearchRowView(
|
||||
}
|
||||
}
|
||||
|
||||
private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi {
|
||||
val chatRh = chat.remoteHostId
|
||||
chatModel.controller.apiSetGroupAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let {
|
||||
withChats {
|
||||
updateGroup(chatRh, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupChatInfoLayout() {
|
||||
@@ -758,6 +772,7 @@ fun PreviewGroupChatInfoLayout() {
|
||||
setSendReceipts = {},
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
onLocalAliasChanged = {},
|
||||
groupLink = null,
|
||||
scrollToItemId = remember { mutableStateOf(null) },
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {},
|
||||
|
||||
@@ -562,6 +562,7 @@
|
||||
<string name="contact_deleted">Contact deleted!</string>
|
||||
<string name="you_can_still_view_conversation_with_contact">You can still view conversation with %1$s in the list of chats.</string>
|
||||
<string name="text_field_set_contact_placeholder">Set contact name…</string>
|
||||
<string name="text_field_set_chat_placeholder">Set chat name…</string>
|
||||
<string name="icon_descr_server_status_connected">Connected</string>
|
||||
<string name="icon_descr_server_status_disconnected">Disconnected</string>
|
||||
<string name="icon_descr_server_status_error">Error</string>
|
||||
|
||||
@@ -219,6 +219,7 @@ library
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20241230_reports
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl
|
||||
other-modules:
|
||||
Paths_simplex_chat
|
||||
hs-source-dirs:
|
||||
|
||||
@@ -342,6 +342,7 @@ data ChatCommand
|
||||
| APIUpdateProfile UserId Profile
|
||||
| APISetContactPrefs ContactId Preferences
|
||||
| APISetContactAlias ContactId LocalAlias
|
||||
| APISetGroupAlias GroupId LocalAlias
|
||||
| APISetConnectionAlias Int64 LocalAlias
|
||||
| APISetUserUIThemes UserId (Maybe UIThemeEntityOverrides)
|
||||
| APISetChatUIThemes ChatRef (Maybe UIThemeEntityOverrides)
|
||||
@@ -379,10 +380,13 @@ data ChatCommand
|
||||
| APIGetUsageConditions
|
||||
| APISetConditionsNotified Int64
|
||||
| APIAcceptConditions Int64 (NonEmpty Int64)
|
||||
| APISetChatItemTTL UserId (Maybe Int64)
|
||||
| SetChatItemTTL (Maybe Int64)
|
||||
| APISetChatItemTTL UserId Int64
|
||||
| SetChatItemTTL Int64
|
||||
| APIGetChatItemTTL UserId
|
||||
| GetChatItemTTL
|
||||
| APISetChatTTL UserId ChatRef (Maybe Int64)
|
||||
| SetChatTTL ChatName (Maybe Int64)
|
||||
| GetChatTTL ChatName
|
||||
| APISetNetworkConfig NetworkConfig
|
||||
| APIGetNetworkConfig
|
||||
| SetNetworkConfig SimpleNetCfg
|
||||
@@ -720,6 +724,7 @@ data ChatResponse
|
||||
| CRUserProfileUpdated {user :: User, fromProfile :: Profile, toProfile :: Profile, updateSummary :: UserProfileUpdateSummary}
|
||||
| CRUserProfileImage {user :: User, profile :: Profile}
|
||||
| CRContactAliasUpdated {user :: User, toContact :: Contact}
|
||||
| CRGroupAliasUpdated {user :: User, toGroup :: GroupInfo}
|
||||
| CRConnectionAliasUpdated {user :: User, toConnection :: PendingContactConnection}
|
||||
| CRContactPrefsUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
|
||||
| CRContactConnecting {user :: User, contact :: Contact}
|
||||
|
||||
@@ -176,7 +176,7 @@ startChatController mainApp enableSndFiles = do
|
||||
startXFTP xftpStartWorkers
|
||||
void $ forkIO $ startFilesToReceive users
|
||||
startCleanupManager
|
||||
void $ forkIO $ startExpireCIs users
|
||||
void $ forkIO $ mapM_ startExpireCIs users
|
||||
else when enableSndFiles $ startXFTP xftpStartSndWorkers
|
||||
pure a1
|
||||
startXFTP startWorkers = do
|
||||
@@ -191,12 +191,15 @@ startChatController mainApp enableSndFiles = do
|
||||
a <- Just <$> async (void $ runExceptT cleanupManager)
|
||||
atomically $ writeTVar cleanupAsync a
|
||||
_ -> pure ()
|
||||
startExpireCIs users =
|
||||
forM_ users $ \user -> do
|
||||
ttl <- fromRight Nothing <$> runExceptT (withStore' (`getChatItemTTL` user))
|
||||
forM_ ttl $ \_ -> do
|
||||
startExpireCIThread user
|
||||
setExpireCIFlag user True
|
||||
startExpireCIs user = whenM shouldExpireChats $ do
|
||||
startExpireCIThread user
|
||||
setExpireCIFlag user True
|
||||
where
|
||||
shouldExpireChats =
|
||||
fmap (fromRight False) $ runExceptT $ withStore' $ \db -> do
|
||||
ttl <- getChatItemTTL db user
|
||||
ttlCount <- getChatTTLCount db user
|
||||
pure $ ttl > 0 || ttlCount > 0
|
||||
|
||||
subscribeUsers :: Bool -> [User] -> CM' ()
|
||||
subscribeUsers onlyNeeded users = do
|
||||
@@ -1256,6 +1259,11 @@ processChatCommand' vr = \case
|
||||
ct <- getContact db vr user contactId
|
||||
liftIO $ updateContactAlias db userId ct localAlias
|
||||
pure $ CRContactAliasUpdated user ct'
|
||||
APISetGroupAlias gId localAlias -> withUser $ \user@User {userId} -> do
|
||||
gInfo' <- withFastStore $ \db -> do
|
||||
gInfo <- getGroupInfo db vr user gId
|
||||
liftIO $ updateGroupAlias db userId gInfo localAlias
|
||||
pure $ CRGroupAliasUpdated user gInfo'
|
||||
APISetConnectionAlias connId localAlias -> withUser $ \user@User {userId} -> do
|
||||
conn' <- withFastStore $ \db -> do
|
||||
conn <- getPendingContactConnection db userId connId
|
||||
@@ -1401,27 +1409,55 @@ processChatCommand' vr = \case
|
||||
currentTs <- liftIO getCurrentTime
|
||||
acceptConditions db condId opIds currentTs
|
||||
CRServerOperatorConditions <$> getServerOperators db
|
||||
APISetChatItemTTL userId newTTL_ -> withUserId userId $ \user ->
|
||||
APISetChatTTL userId (ChatRef cType chatId) newTTL_ ->
|
||||
withUserId userId $ \user -> checkStoreNotChanged $ withChatLock "setChatTTL" $ do
|
||||
(oldTTL_, globalTTL, ttlCount) <- withStore' $ \db ->
|
||||
(,,) <$> getSetChatTTL db <*> getChatItemTTL db user <*> getChatTTLCount db user
|
||||
let newTTL = fromMaybe globalTTL newTTL_
|
||||
oldTTL = fromMaybe globalTTL oldTTL_
|
||||
when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do
|
||||
lift $ setExpireCIFlag user False
|
||||
expireChat user globalTTL `catchChatError` (toView . CRChatError (Just user))
|
||||
lift $ setChatItemsExpiration user globalTTL ttlCount
|
||||
ok user
|
||||
where
|
||||
getSetChatTTL db = case cType of
|
||||
CTDirect -> getDirectChatTTL db chatId <* setDirectChatTTL db chatId newTTL_
|
||||
CTGroup -> getGroupChatTTL db chatId <* setGroupChatTTL db chatId newTTL_
|
||||
_ -> pure Nothing
|
||||
expireChat user globalTTL = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
case cType of
|
||||
CTDirect -> expireContactChatItems user vr globalTTL chatId
|
||||
CTGroup ->
|
||||
let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs
|
||||
in expireGroupChatItems user vr globalTTL createdAtCutoff chatId
|
||||
_ -> throwChatError $ CECommandError "not supported"
|
||||
SetChatTTL chatName newTTL -> withUser' $ \user@User {userId} -> do
|
||||
chatRef <- getChatRef user chatName
|
||||
processChatCommand $ APISetChatTTL userId chatRef newTTL
|
||||
GetChatTTL chatName -> withUser' $ \user -> do
|
||||
ChatRef cType chatId <- getChatRef user chatName
|
||||
ttl <- case cType of
|
||||
CTDirect -> withFastStore' (`getDirectChatTTL` chatId)
|
||||
CTGroup -> withFastStore' (`getGroupChatTTL` chatId)
|
||||
_ -> throwChatError $ CECommandError "not supported"
|
||||
pure $ CRChatItemTTL user ttl
|
||||
APISetChatItemTTL userId newTTL -> withUserId userId $ \user ->
|
||||
checkStoreNotChanged $
|
||||
withChatLock "setChatItemTTL" $ do
|
||||
case newTTL_ of
|
||||
Nothing -> do
|
||||
withFastStore' $ \db -> setChatItemTTL db user newTTL_
|
||||
lift $ setExpireCIFlag user False
|
||||
Just newTTL -> do
|
||||
oldTTL <- withFastStore' (`getChatItemTTL` user)
|
||||
when (maybe True (newTTL <) oldTTL) $ do
|
||||
lift $ setExpireCIFlag user False
|
||||
expireChatItems user newTTL True
|
||||
withFastStore' $ \db -> setChatItemTTL db user newTTL_
|
||||
lift $ startExpireCIThread user
|
||||
lift . whenM chatStarted $ setExpireCIFlag user True
|
||||
(oldTTL, ttlCount) <- withFastStore' $ \db ->
|
||||
(,) <$> getChatItemTTL db user <* setChatItemTTL db user newTTL <*> getChatTTLCount db user
|
||||
when (newTTL > 0 && (newTTL < oldTTL || oldTTL == 0)) $ do
|
||||
lift $ setExpireCIFlag user False
|
||||
expireChatItems user newTTL True
|
||||
lift $ setChatItemsExpiration user newTTL ttlCount
|
||||
ok user
|
||||
SetChatItemTTL newTTL_ -> withUser' $ \User {userId} -> do
|
||||
processChatCommand $ APISetChatItemTTL userId newTTL_
|
||||
APIGetChatItemTTL userId -> withUserId' userId $ \user -> do
|
||||
ttl <- withFastStore' (`getChatItemTTL` user)
|
||||
pure $ CRChatItemTTL user ttl
|
||||
pure $ CRChatItemTTL user (Just ttl)
|
||||
GetChatItemTTL -> withUser' $ \User {userId} -> do
|
||||
processChatCommand $ APIGetChatItemTTL userId
|
||||
APISetNetworkConfig cfg -> withUser' $ \_ -> lift (withAgent' (`setNetworkConfig` cfg)) >> ok_
|
||||
@@ -3246,9 +3282,16 @@ startExpireCIThread user@User {userId} = do
|
||||
atomically $ TM.lookup userId expireFlags >>= \b -> unless (b == Just True) retry
|
||||
lift waitChatStartedAndActivated
|
||||
ttl <- withStore' (`getChatItemTTL` user)
|
||||
forM_ ttl $ \t -> expireChatItems user t False
|
||||
expireChatItems user ttl False
|
||||
liftIO $ threadDelay' interval
|
||||
|
||||
setChatItemsExpiration :: User -> Int64 -> Int -> CM' ()
|
||||
setChatItemsExpiration user newTTL ttlCount
|
||||
| newTTL > 0 || ttlCount > 0 = do
|
||||
startExpireCIThread user
|
||||
whenM chatStarted $ setExpireCIFlag user True
|
||||
| otherwise = setExpireCIFlag user False
|
||||
|
||||
setExpireCIFlag :: User -> Bool -> CM' ()
|
||||
setExpireCIFlag User {userId} b = do
|
||||
expireFlags <- asks expireCIFlags
|
||||
@@ -3496,20 +3539,19 @@ cleanupManager = do
|
||||
withStore' (`deleteOldProbes` cutoffTs)
|
||||
|
||||
expireChatItems :: User -> Int64 -> Bool -> CM ()
|
||||
expireChatItems user@User {userId} ttl sync = do
|
||||
expireChatItems user@User {userId} globalTTL sync = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
vr <- chatVersionRange
|
||||
let expirationDate = addUTCTime (-1 * fromIntegral ttl) currentTs
|
||||
-- this is to keep group messages created during last 12 hours even if they're expired according to item_ts
|
||||
createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs
|
||||
-- this is to keep group messages created during last 12 hours even if they're expired according to item_ts
|
||||
let createdAtCutoff = addUTCTime (-43200 :: NominalDiffTime) currentTs
|
||||
lift waitChatStartedAndActivated
|
||||
contacts <- withStore' $ \db -> getUserContacts db vr user
|
||||
loop contacts $ processContact expirationDate
|
||||
contactIds <- withStore' $ \db -> getUserContactsToExpire db user globalTTL
|
||||
loop contactIds $ expireContactChatItems user vr globalTTL
|
||||
lift waitChatStartedAndActivated
|
||||
groups <- withStore' $ \db -> getUserGroupDetails db vr user Nothing Nothing
|
||||
loop groups $ processGroup vr expirationDate createdAtCutoff
|
||||
groupIds <- withStore' $ \db -> getUserGroupsToExpire db user globalTTL
|
||||
loop groupIds $ expireGroupChatItems user vr globalTTL createdAtCutoff
|
||||
where
|
||||
loop :: [a] -> (a -> CM ()) -> CM ()
|
||||
loop :: [Int64] -> (Int64 -> CM ()) -> CM ()
|
||||
loop [] _ = pure ()
|
||||
loop (a : as) process = continue $ do
|
||||
process a `catchChatError` (toView . CRChatError (Just user))
|
||||
@@ -3522,22 +3564,40 @@ expireChatItems user@User {userId} ttl sync = do
|
||||
expireFlags <- asks expireCIFlags
|
||||
expire <- atomically $ TM.lookup userId expireFlags
|
||||
when (expire == Just True) $ threadDelay 100000 >> a
|
||||
processContact :: UTCTime -> Contact -> CM ()
|
||||
processContact expirationDate ct = do
|
||||
lift waitChatStartedAndActivated
|
||||
filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate
|
||||
cancelFilesInProgress user filesInfo
|
||||
deleteFilesLocally filesInfo
|
||||
withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate
|
||||
processGroup :: VersionRangeChat -> UTCTime -> UTCTime -> GroupInfo -> CM ()
|
||||
processGroup vr expirationDate createdAtCutoff gInfo = do
|
||||
lift waitChatStartedAndActivated
|
||||
filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff
|
||||
cancelFilesInProgress user filesInfo
|
||||
deleteFilesLocally filesInfo
|
||||
withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff
|
||||
membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo
|
||||
forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m
|
||||
|
||||
expireContactChatItems :: User -> VersionRangeChat -> Int64 -> ContactId -> CM ()
|
||||
expireContactChatItems user vr globalTTL ctId =
|
||||
-- reading contacts and groups inside the loop,
|
||||
-- to allow ttl changing while processing and to reduce memory usage
|
||||
tryChatError (withStore $ \db -> getContact db vr user ctId) >>= mapM_ process
|
||||
where
|
||||
process ct@Contact {chatItemTTL} =
|
||||
withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do
|
||||
lift waitChatStartedAndActivated
|
||||
filesInfo <- withStore' $ \db -> getContactExpiredFileInfo db user ct expirationDate
|
||||
cancelFilesInProgress user filesInfo
|
||||
deleteFilesLocally filesInfo
|
||||
withStore' $ \db -> deleteContactExpiredCIs db user ct expirationDate
|
||||
|
||||
expireGroupChatItems :: User -> VersionRangeChat -> Int64 -> UTCTime -> GroupId -> CM ()
|
||||
expireGroupChatItems user vr globalTTL createdAtCutoff groupId =
|
||||
tryChatError (withStore $ \db -> getGroupInfo db vr user groupId) >>= mapM_ process
|
||||
where
|
||||
process gInfo@GroupInfo {chatItemTTL} =
|
||||
withExpirationDate globalTTL chatItemTTL $ \expirationDate -> do
|
||||
lift waitChatStartedAndActivated
|
||||
filesInfo <- withStore' $ \db -> getGroupExpiredFileInfo db user gInfo expirationDate createdAtCutoff
|
||||
cancelFilesInProgress user filesInfo
|
||||
deleteFilesLocally filesInfo
|
||||
withStore' $ \db -> deleteGroupExpiredCIs db user gInfo expirationDate createdAtCutoff
|
||||
membersToDelete <- withStore' $ \db -> getGroupMembersForExpiration db vr user gInfo
|
||||
forM_ membersToDelete $ \m -> withStore' $ \db -> deleteGroupMember db user m
|
||||
|
||||
withExpirationDate :: Int64 -> Maybe Int64 -> (UTCTime -> CM ()) -> CM ()
|
||||
withExpirationDate globalTTL chatItemTTL action = do
|
||||
currentTs <- liftIO getCurrentTime
|
||||
let ttl = fromMaybe globalTTL chatItemTTL
|
||||
when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs
|
||||
|
||||
chatCommandP :: Parser ChatCommand
|
||||
chatCommandP =
|
||||
@@ -3653,6 +3713,7 @@ chatCommandP =
|
||||
"/_network_statuses" $> APIGetNetworkStatuses,
|
||||
"/_profile " *> (APIUpdateProfile <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_set alias @" *> (APISetContactAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
|
||||
"/_set alias #" *> (APISetGroupAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
|
||||
"/_set alias :" *> (APISetConnectionAlias <$> A.decimal <*> (A.space *> textP <|> pure "")),
|
||||
"/_set prefs @" *> (APISetContactPrefs <$> A.decimal <* A.space <*> jsonP),
|
||||
"/_set theme user " *> (APISetUserUIThemes <$> A.decimal <*> optional (A.space *> jsonP)),
|
||||
@@ -3688,10 +3749,13 @@ chatCommandP =
|
||||
"/_conditions" $> APIGetUsageConditions,
|
||||
"/_conditions_notified " *> (APISetConditionsNotified <$> A.decimal),
|
||||
"/_accept_conditions " *> (APIAcceptConditions <$> A.decimal <*> _strP),
|
||||
"/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> ciTTLDecimal),
|
||||
"/ttl " *> (SetChatItemTTL <$> ciTTL),
|
||||
"/_ttl " *> (APISetChatItemTTL <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_ttl " *> (APISetChatTTL <$> A.decimal <* A.space <*> chatRefP <* A.space <*> ciTTLDecimal),
|
||||
"/_ttl " *> (APIGetChatItemTTL <$> A.decimal),
|
||||
"/ttl " *> (SetChatItemTTL <$> ciTTL),
|
||||
"/ttl" $> GetChatItemTTL,
|
||||
"/ttl " *> (SetChatTTL <$> chatNameP <* A.space <*> (("default" $> Nothing) <|> (Just <$> ciTTL))),
|
||||
"/ttl " *> (GetChatTTL <$> chatNameP),
|
||||
"/_network info " *> (APISetNetworkInfo <$> jsonP),
|
||||
"/_network " *> (APISetNetworkConfig <$> jsonP),
|
||||
("/network " <|> "/net ") *> (SetNetworkConfig <$> netCfgP),
|
||||
@@ -3982,12 +4046,13 @@ chatCommandP =
|
||||
chatNameP' = ChatName <$> (chatTypeP <|> pure CTDirect) <*> displayNameP
|
||||
chatRefP = ChatRef <$> chatTypeP <*> A.decimal
|
||||
msgCountP = A.space *> A.decimal <|> pure 10
|
||||
ciTTLDecimal = ("none" $> Nothing) <|> (Just <$> A.decimal)
|
||||
ciTTLDecimal = ("default" $> Nothing) <|> (Just <$> A.decimal)
|
||||
ciTTL =
|
||||
("day" $> Just 86400)
|
||||
<|> ("week" $> Just (7 * 86400))
|
||||
<|> ("month" $> Just (30 * 86400))
|
||||
<|> ("none" $> Nothing)
|
||||
("day" $> 86400)
|
||||
<|> ("week" $> (7 * 86400))
|
||||
<|> ("month" $> (30 * 86400))
|
||||
<|> ("year" $> (365 * 86400))
|
||||
<|> ("none" $> 0)
|
||||
timedTTLP =
|
||||
("30s" $> 30)
|
||||
<|> ("5min" $> 300)
|
||||
|
||||
@@ -110,19 +110,19 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
|
||||
[sql|
|
||||
SELECT
|
||||
c.contact_profile_id, c.local_display_name, c.via_group, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite,
|
||||
p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data
|
||||
p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent, c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl
|
||||
FROM contacts c
|
||||
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
|
||||
WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0
|
||||
|]
|
||||
(userId, contactId)
|
||||
toContact' :: Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact
|
||||
toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) =
|
||||
toContact' contactId conn chatTags ((profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite}
|
||||
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
|
||||
activeConn = Just conn
|
||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, uiThemes, chatDeleted, customData}
|
||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData}
|
||||
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
getGroupAndMember_ groupMemberId c = do
|
||||
gm <-
|
||||
@@ -133,9 +133,9 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image,
|
||||
g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl,
|
||||
-- GroupInfo {membership}
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
|
||||
@@ -82,6 +82,9 @@ module Simplex.Chat.Store.Direct
|
||||
setContactChatDeleted,
|
||||
getDirectChatTags,
|
||||
updateDirectChatTags,
|
||||
setDirectChatTTL,
|
||||
getDirectChatTTL,
|
||||
getUserContactsToExpire
|
||||
)
|
||||
where
|
||||
|
||||
@@ -198,7 +201,7 @@ getContactByConnReqHash db vr user@User {userId} cReqHash = do
|
||||
SELECT
|
||||
-- Contact
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
|
||||
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data,
|
||||
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
|
||||
@@ -263,6 +266,7 @@ createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p
|
||||
contactGroupMemberId = Nothing,
|
||||
contactGrpInvSent = False,
|
||||
chatTags = [],
|
||||
chatItemTTL = Nothing,
|
||||
uiThemes = Nothing,
|
||||
chatDeleted = False,
|
||||
customData = Nothing
|
||||
@@ -659,7 +663,7 @@ createOrUpdateContactRequest db vr user@User {userId, userContactId} userContact
|
||||
SELECT
|
||||
-- Contact
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
|
||||
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data,
|
||||
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
|
||||
@@ -838,6 +842,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
|
||||
contactGroupMemberId = Nothing,
|
||||
contactGrpInvSent = False,
|
||||
chatTags = [],
|
||||
chatItemTTL = Nothing,
|
||||
uiThemes = Nothing,
|
||||
chatDeleted = False,
|
||||
customData = Nothing
|
||||
@@ -873,7 +878,7 @@ getContact_ db vr user@User {userId} contactId deleted = do
|
||||
SELECT
|
||||
-- Contact
|
||||
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
|
||||
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data,
|
||||
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl,
|
||||
-- Connection
|
||||
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias,
|
||||
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter,
|
||||
@@ -1078,3 +1083,19 @@ addDirectChatTags :: DB.Connection -> Contact -> IO Contact
|
||||
addDirectChatTags db ct = do
|
||||
chatTags <- getDirectChatTags db $ contactId' ct
|
||||
pure (ct :: Contact) {chatTags}
|
||||
|
||||
setDirectChatTTL :: DB.Connection -> ContactId -> Maybe Int64 -> IO ()
|
||||
setDirectChatTTL db ctId ttl = do
|
||||
updatedAt <- getCurrentTime
|
||||
DB.execute db "UPDATE contacts SET chat_item_ttl = ?, updated_at = ? WHERE contact_id = ?" (ttl, updatedAt, ctId)
|
||||
|
||||
getDirectChatTTL :: DB.Connection -> ContactId -> IO (Maybe Int64)
|
||||
getDirectChatTTL db ctId =
|
||||
fmap join . maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT chat_item_ttl FROM contacts WHERE contact_id = ? LIMIT 1" (Only ctId)
|
||||
|
||||
getUserContactsToExpire :: DB.Connection -> User -> Int64 -> IO [ContactId]
|
||||
getUserContactsToExpire db User {userId} globalTTL =
|
||||
map fromOnly <$> DB.query db ("SELECT contact_id FROM contacts WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId)
|
||||
where
|
||||
cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL"
|
||||
|
||||
@@ -126,6 +126,10 @@ module Simplex.Chat.Store.Groups
|
||||
setGroupUIThemes,
|
||||
updateGroupChatTags,
|
||||
getGroupChatTags,
|
||||
setGroupChatTTL,
|
||||
getGroupChatTTL,
|
||||
getUserGroupsToExpire,
|
||||
updateGroupAlias,
|
||||
)
|
||||
where
|
||||
|
||||
@@ -160,13 +164,9 @@ import Simplex.Messaging.Protocol (SubscriptionMode (..))
|
||||
import Simplex.Messaging.Util (eitherToMaybe, ($>>=), (<$$>))
|
||||
import Simplex.Messaging.Version
|
||||
import UnliftIO.STM
|
||||
#if defined(dbPostgres)
|
||||
import Database.PostgreSQL.Simple (Only (..), Query, (:.) (..))
|
||||
import Database.PostgreSQL.Simple.SqlQQ (sql)
|
||||
#else
|
||||
|
||||
import Database.SQLite.Simple (Only (..), Query, (:.) (..))
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
#endif
|
||||
|
||||
type MaybeGroupMemberRow = ((Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences))
|
||||
|
||||
@@ -268,9 +268,9 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image,
|
||||
g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl,
|
||||
-- GroupInfo {membership}
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
@@ -337,6 +337,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc
|
||||
{ groupId,
|
||||
localDisplayName = ldn,
|
||||
groupProfile,
|
||||
localAlias = "",
|
||||
businessChat = Nothing,
|
||||
fullGroupPreferences,
|
||||
membership,
|
||||
@@ -347,6 +348,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc
|
||||
chatTs = Just currentTs,
|
||||
userMemberProfileSentAt = Just currentTs,
|
||||
chatTags = [],
|
||||
chatItemTTL = Nothing,
|
||||
uiThemes = Nothing,
|
||||
customData = Nothing
|
||||
}
|
||||
@@ -406,6 +408,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ
|
||||
{ groupId,
|
||||
localDisplayName,
|
||||
groupProfile,
|
||||
localAlias = "",
|
||||
businessChat = Nothing,
|
||||
fullGroupPreferences,
|
||||
membership,
|
||||
@@ -416,6 +419,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ
|
||||
chatTs = Just currentTs,
|
||||
userMemberProfileSentAt = Just currentTs,
|
||||
chatTags = [],
|
||||
chatItemTTL = Nothing,
|
||||
uiThemes = Nothing,
|
||||
customData = Nothing
|
||||
},
|
||||
@@ -646,9 +650,9 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do
|
||||
db
|
||||
[sql|
|
||||
SELECT
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image,
|
||||
g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl,
|
||||
mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction,
|
||||
mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences
|
||||
FROM groups g
|
||||
@@ -1388,9 +1392,9 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image,
|
||||
g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl,
|
||||
-- GroupInfo {membership}
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
@@ -2074,7 +2078,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, chatTags = [], 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 = [], chatItemTTL = Nothing, 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
|
||||
@@ -2111,7 +2115,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, chatTags = [], 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 = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing}
|
||||
m' = m {memberContactId = Just contactId}
|
||||
pure (mCt', m')
|
||||
where
|
||||
@@ -2350,3 +2354,28 @@ untagGroupChat db groupId tId =
|
||||
WHERE group_id = ? AND chat_tag_id = ?
|
||||
|]
|
||||
(groupId, tId)
|
||||
|
||||
setGroupChatTTL :: DB.Connection -> GroupId -> Maybe Int64 -> IO ()
|
||||
setGroupChatTTL db gId ttl = do
|
||||
updatedAt <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
"UPDATE groups SET chat_item_ttl = ?, updated_at = ? WHERE group_id = ?"
|
||||
(ttl, updatedAt, gId)
|
||||
|
||||
getGroupChatTTL :: DB.Connection -> GroupId -> IO (Maybe Int64)
|
||||
getGroupChatTTL db gId =
|
||||
fmap join . maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT chat_item_ttl FROM groups WHERE group_id = ? LIMIT 1" (Only gId)
|
||||
|
||||
getUserGroupsToExpire :: DB.Connection -> User -> Int64 -> IO [GroupId]
|
||||
getUserGroupsToExpire db User {userId} globalTTL =
|
||||
map fromOnly <$> DB.query db ("SELECT group_id FROM groups WHERE user_id = ? AND chat_item_ttl > 0" <> cond) (Only userId)
|
||||
where
|
||||
cond = if globalTTL == 0 then "" else " OR chat_item_ttl IS NULL"
|
||||
|
||||
updateGroupAlias :: DB.Connection -> UserId -> GroupInfo -> LocalAlias -> IO GroupInfo
|
||||
updateGroupAlias db userId g@GroupInfo {groupId} localAlias = do
|
||||
updatedAt <- getCurrentTime
|
||||
DB.execute db "UPDATE groups SET local_alias = ?, updated_at = ? WHERE user_id = ? AND group_id = ?" (localAlias, updatedAt, userId, groupId)
|
||||
pure (g :: GroupInfo) {localAlias = localAlias}
|
||||
|
||||
@@ -107,6 +107,7 @@ module Simplex.Chat.Store.Messages
|
||||
getTimedItems,
|
||||
getChatItemTTL,
|
||||
setChatItemTTL,
|
||||
getChatTTLCount,
|
||||
getContactExpiredFileInfo,
|
||||
deleteContactExpiredCIs,
|
||||
getGroupExpiredFileInfo,
|
||||
@@ -2885,11 +2886,12 @@ getTimedItems db User {userId} startTimedThreadCutoff =
|
||||
(itemId, Nothing, Just groupId, deleteAt) -> Just ((ChatRef CTGroup groupId, itemId), deleteAt)
|
||||
_ -> Nothing
|
||||
|
||||
getChatItemTTL :: DB.Connection -> User -> IO (Maybe Int64)
|
||||
getChatItemTTL :: DB.Connection -> User -> IO Int64
|
||||
getChatItemTTL db User {userId} =
|
||||
fmap join . maybeFirstRow fromOnly $ DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId)
|
||||
fmap (fromMaybe 0 . join) . maybeFirstRow fromOnly $
|
||||
DB.query db "SELECT chat_item_ttl FROM settings WHERE user_id = ? LIMIT 1" (Only userId)
|
||||
|
||||
setChatItemTTL :: DB.Connection -> User -> Maybe Int64 -> IO ()
|
||||
setChatItemTTL :: DB.Connection -> User -> Int64 -> IO ()
|
||||
setChatItemTTL db User {userId} chatItemTTL = do
|
||||
currentTs <- getCurrentTime
|
||||
r :: (Maybe Int64) <- maybeFirstRow fromOnly $ DB.query db "SELECT 1 FROM settings WHERE user_id = ? LIMIT 1" (Only userId)
|
||||
@@ -2905,6 +2907,14 @@ setChatItemTTL db User {userId} chatItemTTL = do
|
||||
"INSERT INTO settings (user_id, chat_item_ttl, created_at, updated_at) VALUES (?,?,?,?)"
|
||||
(userId, chatItemTTL, currentTs, currentTs)
|
||||
|
||||
getChatTTLCount :: DB.Connection -> User -> IO Int
|
||||
getChatTTLCount db User {userId} = do
|
||||
contactCount <- getCount "SELECT COUNT(1) FROM contacts WHERE user_id = ? AND chat_item_ttl > 0"
|
||||
groupCount <- getCount "SELECT COUNT(1) FROM groups WHERE user_id = ? AND chat_item_ttl > 0"
|
||||
pure $ contactCount + groupCount
|
||||
where
|
||||
getCount q = fromOnly . head <$> DB.query db q (Only userId)
|
||||
|
||||
getContactExpiredFileInfo :: DB.Connection -> User -> Contact -> UTCTime -> IO [CIFileInfo]
|
||||
getContactExpiredFileInfo db User {userId} Contact {contactId} expirationDate =
|
||||
map toFileInfo
|
||||
|
||||
@@ -82,6 +82,7 @@ CREATE TABLE contacts(
|
||||
custom_data BYTEA,
|
||||
ui_themes TEXT,
|
||||
chat_deleted SMALLINT NOT NULL DEFAULT 0,
|
||||
chat_item_ttl BIGINT,
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -140,6 +141,8 @@ CREATE TABLE groups(
|
||||
business_chat TEXT NULL,
|
||||
business_xcontact_id BYTEA NULL,
|
||||
customer_member_id BYTEA NULL,
|
||||
chat_item_ttl BIGINT,
|
||||
local_alias TEXT DEFAULT '',
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
|
||||
@@ -123,6 +123,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20241222_operator_conditions
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20241223_chat_tags
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20241230_reports
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20250105_indexes
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -245,7 +246,8 @@ schemaMigrations =
|
||||
("20241222_operator_conditions", m20241222_operator_conditions, Just down_m20241222_operator_conditions),
|
||||
("20241223_chat_tags", m20241223_chat_tags, Just down_m20241223_chat_tags),
|
||||
("20241230_reports", m20241230_reports, Just down_m20241230_reports),
|
||||
("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes)
|
||||
("20250105_indexes", m20250105_indexes, Just down_m20250105_indexes),
|
||||
("20250115_chat_ttl", m20250115_chat_ttl, Just down_m20250115_chat_ttl)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.SQLite.Migrations.M20250115_chat_ttl where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20250115_chat_ttl :: Query
|
||||
m20250115_chat_ttl =
|
||||
[sql|
|
||||
ALTER TABLE contacts ADD COLUMN chat_item_ttl INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN chat_item_ttl INTEGER;
|
||||
ALTER TABLE groups ADD COLUMN local_alias TEXT DEFAULT '';
|
||||
|]
|
||||
|
||||
down_m20250115_chat_ttl :: Query
|
||||
down_m20250115_chat_ttl =
|
||||
[sql|
|
||||
ALTER TABLE contacts DROP COLUMN chat_item_ttl;
|
||||
ALTER TABLE groups DROP COLUMN chat_item_ttl;
|
||||
ALTER TABLE groups DROP COLUMN local_alias;
|
||||
|]
|
||||
@@ -78,6 +78,7 @@ CREATE TABLE contacts(
|
||||
custom_data BLOB,
|
||||
ui_themes TEXT,
|
||||
chat_deleted INTEGER NOT NULL DEFAULT 0,
|
||||
chat_item_ttl INTEGER,
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
@@ -131,7 +132,9 @@ CREATE TABLE groups(
|
||||
business_member_id BLOB NULL,
|
||||
business_chat TEXT NULL,
|
||||
business_xcontact_id BLOB NULL,
|
||||
customer_member_id BLOB NULL, -- received
|
||||
customer_member_id BLOB NULL,
|
||||
chat_item_ttl INTEGER,
|
||||
local_alias TEXT DEFAULT '', -- received
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
|
||||
@@ -414,18 +414,18 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId =
|
||||
|]
|
||||
(userId, profileId, userId, profileId, userId, profileId)
|
||||
|
||||
type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData)
|
||||
type ContactRow' = (ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. (Maybe GroupMemberId, BoolInt, Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64)
|
||||
|
||||
type ContactRow = Only ContactId :. ContactRow'
|
||||
|
||||
toContact :: VersionRangeChat -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact
|
||||
toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData)) :. connRow) =
|
||||
toContact vr user chatTags ((Only contactId :. (profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. (contactGroupMemberId, BI contactGrpInvSent, uiThemes, BI chatDeleted, customData, chatItemTTL)) :. connRow) =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
|
||||
activeConn = toMaybeConnection vr connRow
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> 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, chatTags, uiThemes, chatDeleted, customData}
|
||||
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent, chatTags, chatItemTTL, uiThemes, chatDeleted, customData}
|
||||
|
||||
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile
|
||||
getProfileById db userId profileId =
|
||||
@@ -575,18 +575,18 @@ safeDeleteLDN db User {userId} localDisplayName = do
|
||||
|
||||
type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId)
|
||||
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData) :. GroupMemberRow
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe ProfileId, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow
|
||||
|
||||
type GroupMemberRow = ((Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Preferences))
|
||||
|
||||
toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo
|
||||
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData) :. userMemberRow) =
|
||||
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, hostConnCustomUserProfileId, enableNtfs_, sendRcpts, BI favorite, groupPreferences) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) =
|
||||
let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> 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, chatTags, uiThemes, customData}
|
||||
in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, hostConnCustomUserProfileId, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData}
|
||||
|
||||
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
|
||||
toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences)) =
|
||||
@@ -607,9 +607,9 @@ groupInfoQuery =
|
||||
[sql|
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.description, gp.image,
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image,
|
||||
g.host_conn_custom_user_profile_id, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl,
|
||||
-- GroupMember - membership
|
||||
mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
|
||||
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
|
||||
|
||||
@@ -188,6 +188,7 @@ data Contact = Contact
|
||||
contactGroupMemberId :: Maybe GroupMemberId,
|
||||
contactGrpInvSent :: Bool,
|
||||
chatTags :: [ChatTagId],
|
||||
chatItemTTL :: Maybe Int64,
|
||||
uiThemes :: Maybe UIThemeEntityOverrides,
|
||||
chatDeleted :: Bool,
|
||||
customData :: Maybe CustomData
|
||||
@@ -381,6 +382,7 @@ data GroupInfo = GroupInfo
|
||||
{ groupId :: GroupId,
|
||||
localDisplayName :: GroupName,
|
||||
groupProfile :: GroupProfile,
|
||||
localAlias :: Text,
|
||||
businessChat :: Maybe BusinessChatInfo,
|
||||
fullGroupPreferences :: FullGroupPreferences,
|
||||
membership :: GroupMember,
|
||||
@@ -391,6 +393,7 @@ data GroupInfo = GroupInfo
|
||||
chatTs :: Maybe UTCTime,
|
||||
userMemberProfileSentAt :: Maybe UTCTime,
|
||||
chatTags :: [ChatTagId],
|
||||
chatItemTTL :: Maybe Int64,
|
||||
uiThemes :: Maybe UIThemeEntityOverrides,
|
||||
customData :: Maybe CustomData
|
||||
}
|
||||
|
||||
@@ -237,6 +237,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe
|
||||
CRUserProfileImage u p -> ttyUser u $ viewUserProfileImage p
|
||||
CRContactPrefsUpdated {user = u, fromContact, toContact} -> ttyUser u $ viewUserContactPrefsUpdated u fromContact toContact
|
||||
CRContactAliasUpdated u c -> ttyUser u $ viewContactAliasUpdated c
|
||||
CRGroupAliasUpdated u g -> ttyUser u $ viewGroupAliasUpdated g
|
||||
CRConnectionAliasUpdated u c -> ttyUser u $ viewConnectionAliasUpdated c
|
||||
CRContactUpdated {user = u, fromContact = c, toContact = c'} -> ttyUser u $ viewContactUpdated c c' <> viewContactPrefsUpdated u c c'
|
||||
CRGroupMemberUpdated {} -> []
|
||||
@@ -1182,7 +1183,7 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs
|
||||
groupSS (g@GroupInfo {membership, chatSettings = ChatSettings {enableNtfs}}, GroupSummary {currentMembers}) =
|
||||
case memberStatus membership of
|
||||
GSMemInvited -> groupInvitation' g
|
||||
s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s
|
||||
s -> membershipIncognito g <> ttyFullGroup g <> viewMemberStatus s <> alias g
|
||||
where
|
||||
viewMemberStatus = \case
|
||||
GSMemRemoved -> delete "you are removed"
|
||||
@@ -1197,6 +1198,9 @@ viewGroupsList gs = map groupSS $ sortOn (ldn_ . fst) gs
|
||||
unmute = "you can " <> highlight ("/unmute #" <> viewGroupName g)
|
||||
delete reason = " (" <> reason <> ", delete local copy: " <> highlight ("/d #" <> viewGroupName g) <> ")"
|
||||
memberCount = sShow currentMembers <> " member" <> if currentMembers == 1 then "" else "s"
|
||||
alias GroupInfo {localAlias}
|
||||
| localAlias == "" = ""
|
||||
| otherwise = " (alias: " <> plain localAlias <> ")"
|
||||
|
||||
groupInvitation' :: GroupInfo -> StyledString
|
||||
groupInvitation' g@GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} =
|
||||
@@ -1359,11 +1363,13 @@ viewUsageConditions current accepted_ =
|
||||
|
||||
viewChatItemTTL :: Maybe Int64 -> [StyledString]
|
||||
viewChatItemTTL = \case
|
||||
Nothing -> ["old messages are not being deleted"]
|
||||
Nothing -> ["old messages are set to delete according to default user config"]
|
||||
Just ttl
|
||||
| ttl == 0 -> ["old messages are not being deleted"]
|
||||
| ttl == 86400 -> deletedAfter "one day"
|
||||
| ttl == 7 * 86400 -> deletedAfter "one week"
|
||||
| ttl == 30 * 86400 -> deletedAfter "one month"
|
||||
| ttl == 365 * 86400 -> deletedAfter "one year"
|
||||
| otherwise -> deletedAfter $ sShow ttl <> " second(s)"
|
||||
where
|
||||
deletedAfter ttlStr = ["old messages are set to be deleted after: " <> ttlStr]
|
||||
@@ -1626,6 +1632,11 @@ viewContactAliasUpdated ct@Contact {profile = LocalProfile {localAlias}}
|
||||
| localAlias == "" = ["contact " <> ttyContact' ct <> " alias removed"]
|
||||
| otherwise = ["contact " <> ttyContact' ct <> " alias updated: " <> plain localAlias]
|
||||
|
||||
viewGroupAliasUpdated :: GroupInfo -> [StyledString]
|
||||
viewGroupAliasUpdated g@GroupInfo {localAlias}
|
||||
| localAlias == "" = ["group " <> ttyGroup' g <> " alias removed"]
|
||||
| otherwise = ["group " <> ttyGroup' g <> " alias updated: " <> plain localAlias]
|
||||
|
||||
viewConnectionAliasUpdated :: PendingContactConnection -> [StyledString]
|
||||
viewConnectionAliasUpdated PendingContactConnection {pccConnId, localAlias}
|
||||
| localAlias == "" = ["connection " <> sShow pccConnId <> " alias removed"]
|
||||
|
||||
@@ -134,6 +134,7 @@ chatDirectTests = do
|
||||
it "disabling chat item expiration doesn't disable it for other users" testDisableCIExpirationOnlyForOneUser
|
||||
it "both users have configured timed messages with contacts, messages expire, restart" testUsersTimedMessages
|
||||
it "user profile privacy: hide profiles and notifications" testUserPrivacy
|
||||
it "set direct chat expiration TTL" testSetDirectChatTTL
|
||||
describe "settings" $ do
|
||||
it "set chat item expiration TTL" testSetChatItemTTL
|
||||
it "save/get app settings" testAppSettings
|
||||
@@ -2116,7 +2117,7 @@ testUsersRestartCIExpiration tmp = do
|
||||
showActiveUser alice "alisa"
|
||||
alice #$> ("/_get chat @6 count=100", chat, chatFeatures <> [(1, "alisa 1"), (0, "alisa 2"), (1, "alisa 3"), (0, "alisa 4")])
|
||||
|
||||
threadDelay 3000000
|
||||
threadDelay 4000000
|
||||
|
||||
alice #$> ("/_get chat @6 count=100", chat, [])
|
||||
where
|
||||
@@ -2561,6 +2562,82 @@ testSetChatItemTTL =
|
||||
alice #$> ("/ttl none", id, "ok")
|
||||
alice #$> ("/ttl", id, "old messages are not being deleted")
|
||||
|
||||
testSetDirectChatTTL :: HasCallStack => FilePath -> IO ()
|
||||
testSetDirectChatTTL =
|
||||
testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
connectUsers alice bob
|
||||
connectUsers alice cath
|
||||
alice #> "@bob 1"
|
||||
bob <# "alice> 1"
|
||||
bob #> "@alice 2"
|
||||
alice <# "bob> 2"
|
||||
-- above items should be deleted after we set ttl
|
||||
alice #> "@cath 10"
|
||||
cath <# "alice> 10"
|
||||
cath #> "@alice 11"
|
||||
alice <# "cath> 11"
|
||||
alice #$> ("/ttl @cath none", id, "ok")
|
||||
alice #$> ("/ttl @cath", id, "old messages are not being deleted")
|
||||
|
||||
threadDelay 3000000
|
||||
alice #> "@bob 3"
|
||||
bob <# "alice> 3"
|
||||
bob #> "@alice 4"
|
||||
alice <# "bob> 4"
|
||||
alice #$> ("/_get chat @2 count=100", chatF, chatFeaturesF <> [((1, "1"), Nothing), ((0, "2"), Nothing), ((1, "3"), Nothing), ((0, "4"), Nothing)])
|
||||
alice #$> ("/_ttl 1 2", id, "ok")
|
||||
-- when expiration is turned on, first cycle is synchronous
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4")])
|
||||
|
||||
-- chat @3 doesn't expire since it was set to not expire
|
||||
alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4")])
|
||||
|
||||
-- remove global ttl
|
||||
alice #$> ("/ttl none", id, "ok")
|
||||
alice #> "@bob 5"
|
||||
bob <# "alice> 5"
|
||||
bob #> "@alice 6"
|
||||
alice <# "bob> 6"
|
||||
alice #$> ("/_get chat @3 count=100", chat, chatFeatures <> [(1, "10"), (0, "11")])
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")])
|
||||
|
||||
-- set ttl for chat @3, only chat @3 is affected since global ttl is disabled
|
||||
alice #$> ("/_ttl 1 @3 1", id, "ok")
|
||||
alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: 1 second(s)")
|
||||
threadDelay 3000000
|
||||
alice #$> ("/_get chat @3 count=100", chat, [])
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")])
|
||||
|
||||
-- set ttl to never expire again
|
||||
alice #$> ("/ttl @cath none", id, "ok")
|
||||
alice #> "@cath 12"
|
||||
cath <# "alice> 12"
|
||||
cath #> "@alice 13"
|
||||
alice <# "cath> 13"
|
||||
threadDelay 3000000
|
||||
alice #$> ("/_get chat @3 count=100", chat, [(1, "12"), (0, "13")])
|
||||
alice #$> ("/_get chat @2 count=100", chat, [(1, "3"), (0, "4"), (1, "5"), (0, "6")])
|
||||
bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "1"), (1, "2"), (0, "3"), (1, "4"), (0, "5"), (1, "6")])
|
||||
|
||||
-- set ttl back to default
|
||||
alice #$> ("/ttl @cath default", id, "ok")
|
||||
alice #$> ("/ttl @cath", id, "old messages are set to delete according to default user config")
|
||||
alice #$> ("/_ttl 1 2", id, "ok")
|
||||
alice #$> ("/_get chat @3 count=100", chat, [])
|
||||
alice #$> ("/_get chat @2 count=100", chat, [])
|
||||
|
||||
alice #$> ("/ttl @cath day", id, "ok")
|
||||
alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one day")
|
||||
alice #$> ("/ttl @cath week", id, "ok")
|
||||
alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one week")
|
||||
alice #$> ("/ttl @cath month", id, "ok")
|
||||
alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one month")
|
||||
alice #$> ("/ttl @cath year", id, "ok")
|
||||
alice #$> ("/ttl @cath", id, "old messages are set to be deleted after: one year")
|
||||
|
||||
testAppSettings :: HasCallStack => FilePath -> IO ()
|
||||
testAppSettings tmp =
|
||||
withNewTestChat tmp "alice" aliceProfile $ \alice -> do
|
||||
|
||||
@@ -77,6 +77,8 @@ chatProfileTests = do
|
||||
describe "contact aliases" $ do
|
||||
it "set contact alias" testSetAlias
|
||||
it "set connection alias" testSetConnectionAlias
|
||||
describe "group aliases" $ do
|
||||
it "set group alias" testSetGroupAlias
|
||||
describe "pending connection users" $ do
|
||||
it "change user for pending connection" testChangePCCUser
|
||||
it "change from incognito profile connects as new user" testChangePCCUserFromIncognito
|
||||
@@ -1978,6 +1980,20 @@ testSetConnectionAlias = testChat2 aliceProfile bobProfile $
|
||||
alice ##> "/contacts"
|
||||
alice <## "bob (Bob) (alias: friend)"
|
||||
|
||||
testSetGroupAlias :: HasCallStack => FilePath -> IO ()
|
||||
testSetGroupAlias = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
createGroup2 "team" alice bob
|
||||
threadDelay 1500000
|
||||
alice ##> "/_set alias #1 friends"
|
||||
alice <## "group #team alias updated: friends"
|
||||
alice ##> "/groups"
|
||||
alice <## "#team (2 members) (alias: friends)"
|
||||
alice ##> "/_set alias #1"
|
||||
alice <## "group #team alias removed"
|
||||
alice ##> "/groups"
|
||||
alice <## "#team (2 members)"
|
||||
|
||||
testSetContactPrefs :: HasCallStack => FilePath -> IO ()
|
||||
testSetContactPrefs = testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> withXFTPServer $ do
|
||||
|
||||
Reference in New Issue
Block a user