mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-05 06:17:34 +00:00
core: link notices
This commit is contained in:
1 parent
3ed4511e80
commit
40fc230f06
50 files changed
+1329
-353
No files matched your search
@@ -82,7 +82,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiDeleteMemberSupportChat(groupId: Int64, groupMemberId: Int64)
|
||||
case apiMembersRole(groupId: Int64, memberIds: [Int64], memberRole: GroupMemberRole)
|
||||
case apiBlockMembersForAll(groupId: Int64, memberIds: [Int64], blocked: Bool)
|
||||
case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool)
|
||||
case apiRemoveMembers(groupId: Int64, memberIds: [Int64], withMessages: Bool, notice: LinkNotice?)
|
||||
case apiLeaveGroup(groupId: Int64)
|
||||
case apiListMembers(groupId: Int64)
|
||||
case apiUpdateGroupProfile(groupId: Int64, groupProfile: GroupProfile)
|
||||
@@ -142,7 +142,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?)
|
||||
case apiConnect(userId: Int64, incognito: Bool, connLink: CreatedConnLink)
|
||||
case apiConnectContactViaAddress(userId: Int64, incognito: Bool, contactId: Int64)
|
||||
case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode)
|
||||
case apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode, notice: LinkNotice?)
|
||||
case apiClearChat(type: ChatType, id: Int64)
|
||||
case apiListContacts(userId: Int64)
|
||||
case apiUpdateProfile(userId: Int64, profile: Profile)
|
||||
@@ -263,7 +263,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
let msgs = encodeJSON(composedMessages)
|
||||
return "/_create *\(noteFolderId) json \(msgs)"
|
||||
case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
|
||||
return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)"
|
||||
return "/_report #\(groupId) \(chatItemId) reason=\(reportReason.rawValue) \(reportText)"
|
||||
case let .apiUpdateChatItem(type, id, scope, itemId, um, live): return "/_update item \(ref(type, id, scope: scope)) \(itemId) live=\(onOff(live)) \(um.cmdString)"
|
||||
case let .apiDeleteChatItem(type, id, scope, itemIds, mode): return "/_delete item \(ref(type, id, scope: scope)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
|
||||
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
|
||||
@@ -299,7 +299,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case let .apiDeleteMemberSupportChat(groupId, groupMemberId): return "/_delete member chat #\(groupId) \(groupMemberId)"
|
||||
case let .apiMembersRole(groupId, memberIds, memberRole): return "/_member role #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) \(memberRole.rawValue)"
|
||||
case let .apiBlockMembersForAll(groupId, memberIds, blocked): return "/_block #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) blocked=\(onOff(blocked))"
|
||||
case let .apiRemoveMembers(groupId, memberIds, withMessages): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))"
|
||||
case let .apiRemoveMembers(groupId, memberIds, withMessages, notice): return "/_remove #\(groupId) \(memberIds.map({ "\($0)" }).joined(separator: ",")) messages=\(onOff(withMessages))\(noticeCmdString(notice))"
|
||||
case let .apiLeaveGroup(groupId): return "/_leave #\(groupId)"
|
||||
case let .apiListMembers(groupId): return "/_members #\(groupId)"
|
||||
case let .apiUpdateGroupProfile(groupId, groupProfile): return "/_group_profile #\(groupId) \(encodeJSON(groupProfile))"
|
||||
@@ -371,7 +371,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case let .apiConnectPreparedGroup(groupId, incognito, mc): return "/_connect group #\(groupId) incognito=\(onOff(incognito))\(maybeContent(mc))"
|
||||
case let .apiConnect(userId, incognito, connLink): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connLink.connFullLink) \(connLink.connShortLink ?? "")"
|
||||
case let .apiConnectContactViaAddress(userId, incognito, contactId): return "/_connect contact \(userId) incognito=\(onOff(incognito)) \(contactId)"
|
||||
case let .apiDeleteChat(type, id, chatDeleteMode): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)"
|
||||
case let .apiDeleteChat(type, id, chatDeleteMode, notice): return "/_delete \(ref(type, id, scope: nil)) \(chatDeleteMode.cmdString)\(noticeCmdString(notice))"
|
||||
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id, scope: nil))"
|
||||
case let .apiListContacts(userId): return "/_contacts \(userId)"
|
||||
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
|
||||
@@ -1478,6 +1478,7 @@ enum ContactAddressPlan: Decodable, Hashable {
|
||||
case connectingProhibit(contact: Contact)
|
||||
case known(contact: Contact)
|
||||
case contactViaAddress(contact: Contact)
|
||||
case linkNotice(expiresAt: Date?, reason: ReportReason?)
|
||||
}
|
||||
|
||||
public struct GroupShortLinkInfo: Decodable, Hashable {
|
||||
@@ -1494,6 +1495,7 @@ enum GroupLinkPlan: Decodable, Hashable {
|
||||
case known(groupInfo: GroupInfo)
|
||||
case noRelays(groupSLinkData_: GroupShortLinkData?)
|
||||
case updateRequired(groupSLinkData_: GroupShortLinkData?)
|
||||
case linkNotice(expiresAt: Date?, reason: ReportReason?)
|
||||
}
|
||||
|
||||
struct ChatTagData: Encodable {
|
||||
@@ -1510,6 +1512,15 @@ struct UpdatedMessage: Encodable {
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkNotice: Encodable {
|
||||
var ttl: Int64?
|
||||
var reason: ReportReason?
|
||||
}
|
||||
|
||||
func noticeCmdString(_ notice: LinkNotice?) -> String {
|
||||
notice.map { " notice=\(encodeJSON($0))" } ?? ""
|
||||
}
|
||||
|
||||
enum ChatDeleteMode: Codable {
|
||||
case full(notify: Bool)
|
||||
case entity(notify: Bool)
|
||||
|
||||
@@ -1079,7 +1079,7 @@ func apiConnect(incognito: Bool, connLink: CreatedConnLink) async -> (ConnReqTyp
|
||||
return nil
|
||||
}
|
||||
|
||||
private func apiConnectResponseAlert<R>(_ r: APIResult<R>) async {
|
||||
private func apiConnectResponseAlert<R>(_ r: APIResult<R>, _ noticeTarget: LinkNoticeTarget = .link) async {
|
||||
await MainActor.run {
|
||||
switch r.unexpected {
|
||||
case .error(.invalidConnReq):
|
||||
@@ -1092,6 +1092,8 @@ private func apiConnectResponseAlert<R>(_ r: APIResult<R>) async {
|
||||
NSLocalizedString("Unsupported connection link", comment: ""),
|
||||
message: NSLocalizedString("This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link.", comment: "")
|
||||
)
|
||||
case let .error(.linkNotice(expiresAt, reason)):
|
||||
showLinkNoticeAlert(noticeTarget, expiresAt: expiresAt, reason: reason)
|
||||
case let .error(.simplexDomainNotReady(domain, err)):
|
||||
switch err {
|
||||
case .noValidLink:
|
||||
@@ -1231,14 +1233,14 @@ func apiChangePreparedGroupUser(groupId: Int64, newUserId: Int64) async throws -
|
||||
func apiConnectPreparedContact(contactId: Int64, incognito: Bool, msg: MsgContent?) async -> Contact? {
|
||||
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedContact(contactId: contactId, incognito: incognito, msg: msg))
|
||||
if case let .result(.startedConnectionToContact(_, contact)) = r { return contact }
|
||||
if let r { await apiConnectResponseAlert(r) }
|
||||
if let r { await apiConnectResponseAlert(r, .address) }
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiConnectPreparedGroup(groupId: Int64, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? {
|
||||
func apiConnectPreparedGroup(groupId: Int64, isChannel: Bool, incognito: Bool, msg: MsgContent?) async -> (GroupInfo, [RelayConnectionResult])? {
|
||||
let r: APIResult<ChatResponse1>? = await chatApiSendCmdWithRetry(.apiConnectPreparedGroup(groupId: groupId, incognito: incognito, msg: msg))
|
||||
if case let .result(.startedConnectionToGroup(_, groupInfo, relayResults)) = r { return (groupInfo, relayResults) }
|
||||
if let r { await apiConnectResponseAlert(r) }
|
||||
if let r { await apiConnectResponseAlert(r, isChannel ? .channel : .group) }
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1251,23 +1253,29 @@ func apiConnectContactViaAddress(incognito: Bool, contactId: Int64) async -> Con
|
||||
if case let .result(.sentInvitationToContact(_, contact, _)) = r { return contact }
|
||||
if let r {
|
||||
logger.error("apiConnectContactViaAddress error: \(responseError(r.unexpected))")
|
||||
await MainActor.run { showAlert(connectionErrorAlert(r)) }
|
||||
await MainActor.run {
|
||||
if case let .error(.linkNotice(expiresAt, reason)) = r.unexpected {
|
||||
showLinkNoticeAlert(.address, expiresAt: expiresAt, reason: reason)
|
||||
} else {
|
||||
showAlert(connectionErrorAlert(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws {
|
||||
func apiDeleteChat(type: ChatType, id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true), notice: LinkNotice? = nil) async throws {
|
||||
let chatId = type.rawValue + id.description
|
||||
DispatchQueue.main.async { ChatModel.shared.deletedChats.insert(chatId) }
|
||||
defer { DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) } }
|
||||
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
|
||||
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode, notice: notice), bgTask: false)
|
||||
if case .direct = type, case .contactDeleted = r { return }
|
||||
if case .contactConnection = type, case .contactConnectionDeleted = r { return }
|
||||
if case .group = type, case .groupDeletedUser = r { return }
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async throws -> Contact {
|
||||
func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify: true), notice: LinkNotice? = nil) async throws -> Contact {
|
||||
let type: ChatType = .direct
|
||||
let chatId = type.rawValue + id.description
|
||||
if case .full = chatDeleteMode {
|
||||
@@ -1278,7 +1286,7 @@ func apiDeleteContact(id: Int64, chatDeleteMode: ChatDeleteMode = .full(notify:
|
||||
DispatchQueue.main.async { ChatModel.shared.deletedChats.remove(chatId) }
|
||||
}
|
||||
}
|
||||
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode), bgTask: false)
|
||||
let r: ChatResponse1 = try await chatSendCmd(.apiDeleteChat(type: type, id: id, chatDeleteMode: chatDeleteMode, notice: notice), bgTask: false)
|
||||
if case let .contactDeleted(_, contact) = r { return contact }
|
||||
throw r.unexpected
|
||||
}
|
||||
@@ -1297,10 +1305,10 @@ func deleteChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: tru
|
||||
}
|
||||
}
|
||||
|
||||
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true)) async -> Alert? {
|
||||
func deleteContactChat(_ chat: Chat, chatDeleteMode: ChatDeleteMode = .full(notify: true), notice: LinkNotice? = nil) async -> Alert? {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode)
|
||||
let ct = try await apiDeleteContact(id: cInfo.apiId, chatDeleteMode: chatDeleteMode, notice: notice)
|
||||
await MainActor.run {
|
||||
switch chatDeleteMode {
|
||||
case .full:
|
||||
@@ -2022,8 +2030,8 @@ func apiDeleteMemberSupportChat(_ groupId: Int64, _ groupMemberId: Int64) async
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool) async throws -> (GroupInfo, [GroupMember]) {
|
||||
let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages), bgTask: false)
|
||||
func apiRemoveMembers(_ groupId: Int64, _ memberIds: [Int64], _ withMessages: Bool, notice: LinkNotice? = nil) async throws -> (GroupInfo, [GroupMember]) {
|
||||
let r: ChatResponse2 = try await chatSendCmd(.apiRemoveMembers(groupId: groupId, memberIds: memberIds, withMessages: withMessages, notice: notice), bgTask: false)
|
||||
if case let .userDeletedMembers(_, updatedGroupInfo, members, _withMessages) = r { return (updatedGroupInfo, members) }
|
||||
throw r.unexpected
|
||||
}
|
||||
|
||||
@@ -1179,7 +1179,8 @@ private func deleteContactOrConversationDialog(
|
||||
showAlert: showAlert
|
||||
)
|
||||
) },
|
||||
id: "DeleteActiveContactDialog"
|
||||
id: "DeleteActiveContactDialog",
|
||||
fraction: contact.activeConn?.viaUserContactLink == nil ? 0.4 : 0.55
|
||||
))
|
||||
},
|
||||
.cancel()
|
||||
@@ -1193,11 +1194,12 @@ private func deleteContactMaybeErrorAlert(
|
||||
_ chat: Chat,
|
||||
_ contact: Contact,
|
||||
chatDeleteMode: ChatDeleteMode,
|
||||
notice: LinkNotice? = nil,
|
||||
_ dismissToChatList: Bool,
|
||||
_ showAlert: @escaping (SomeAlert) -> Void
|
||||
) {
|
||||
Task {
|
||||
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode)
|
||||
let alert_ = await deleteContactChat(chat, chatDeleteMode: chatDeleteMode, notice: notice)
|
||||
if let alert = alert_ {
|
||||
showAlert(SomeAlert(alert: alert, id: "deleteContactMaybeErrorAlert, error"))
|
||||
} else {
|
||||
@@ -1267,6 +1269,8 @@ struct DeleteActiveContactDialog: View {
|
||||
var dismissToChatList: Bool
|
||||
var showAlert: (SomeAlert) -> Void
|
||||
@State private var keepConversation = false
|
||||
@State private var ban: LinkBan = .no
|
||||
@State private var reason: ReportReason = .spam
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
@@ -1274,16 +1278,20 @@ struct DeleteActiveContactDialog: View {
|
||||
Section {
|
||||
Toggle("Keep conversation", isOn: $keepConversation)
|
||||
|
||||
if contact.activeConn?.viaUserContactLink != nil {
|
||||
linkBanRows("Ban from connecting", $ban, $reason)
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), dismissToChatList, showAlert)
|
||||
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: false), notice: ban.notice(reason), dismissToChatList, showAlert)
|
||||
} label: {
|
||||
Text("Delete without notification")
|
||||
}
|
||||
|
||||
Button(role: .destructive) {
|
||||
dismiss()
|
||||
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), dismissToChatList, showAlert)
|
||||
deleteContactMaybeErrorAlert(chat, contact, chatDeleteMode: contactDeleteMode.toChatDeleteMode(notify: true), notice: ban.notice(reason), dismissToChatList, showAlert)
|
||||
} label: {
|
||||
Text("Delete and notify contact")
|
||||
}
|
||||
|
||||
@@ -1208,7 +1208,7 @@ struct ComposeView: View {
|
||||
await sending()
|
||||
let mc = connectCheckLinkPreview()
|
||||
let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
|
||||
if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, incognito: incognito, msg: mc) {
|
||||
if let (groupInfo, relayResults) = await apiConnectPreparedGroup(groupId: chat.chatInfo.apiId, isChannel: chat.chatInfo.isChannel, incognito: incognito, msg: mc) {
|
||||
await MainActor.run {
|
||||
self.chatModel.updateGroup(groupInfo)
|
||||
self.chatModel.channelRelayHostnames.removeValue(forKey: groupInfo.groupId)
|
||||
@@ -1421,7 +1421,7 @@ struct ComposeView: View {
|
||||
case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason")
|
||||
case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason")
|
||||
case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason")
|
||||
case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason")
|
||||
case .content: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason")
|
||||
case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason")
|
||||
case .unknown: "" // Should never happen
|
||||
}
|
||||
|
||||
@@ -45,11 +45,16 @@ struct ContextPendingMemberActionsView: View {
|
||||
}
|
||||
|
||||
func showRejectMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismiss: DismissAction? = nil) {
|
||||
showAlert(
|
||||
title: NSLocalizedString("Reject member?", comment: "alert title"),
|
||||
buttonTitle: "Reject",
|
||||
buttonAction: { removeMember(groupInfo, member, withMessages: false, dismiss: dismiss) },
|
||||
cancelButton: true
|
||||
showMemberBanAlert(
|
||||
groupInfo,
|
||||
member,
|
||||
NSLocalizedString("Reject member?", comment: "alert title"),
|
||||
message: nil,
|
||||
actions: [
|
||||
LinkBanAction(title: NSLocalizedString("Reject", comment: "alert action")) { notice in
|
||||
removeMember(groupInfo, member, withMessages: false, notice: notice, dismiss: dismiss)
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -874,7 +874,14 @@ struct GroupChatInfoView: View {
|
||||
@ViewBuilder private func deleteGroupButton() -> some View {
|
||||
let label: LocalizedStringKey = groupInfo.useRelays ? "Delete channel" : groupInfo.businessChat == nil ? "Delete group" : "Delete chat"
|
||||
Button(role: .destructive) {
|
||||
alert = .deleteGroupAlert
|
||||
if canBanOnChatDeletion(groupInfo) {
|
||||
showDeleteBusinessChatAlert(chat) {
|
||||
dismiss()
|
||||
chatModel.chatId = nil
|
||||
}
|
||||
} else {
|
||||
alert = .deleteGroupAlert
|
||||
}
|
||||
} label: {
|
||||
Label(label, systemImage: "trash")
|
||||
.foregroundColor(Color.red)
|
||||
@@ -1010,43 +1017,54 @@ func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss:
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
} else if groupInfo.useRelays {
|
||||
showAlert(
|
||||
NSLocalizedString("Remove subscriber?", comment: "alert title"),
|
||||
message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
} else {
|
||||
showAlert(
|
||||
NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
message: groupInfo.businessChat == nil
|
||||
showMemberBanAlert(
|
||||
groupInfo,
|
||||
mem,
|
||||
groupInfo.useRelays
|
||||
? NSLocalizedString("Remove subscriber?", comment: "alert title")
|
||||
: NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
message: groupInfo.useRelays
|
||||
? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message")
|
||||
: groupInfo.businessChat == nil
|
||||
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
|
||||
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
actions: [
|
||||
LinkBanAction(title: NSLocalizedString("Remove", comment: "alert action")) { notice in
|
||||
removeMember(groupInfo, mem, withMessages: false, notice: notice, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
LinkBanAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action")) { notice in
|
||||
removeMember(groupInfo, mem, withMessages: true, notice: notice, dismiss: dismiss)
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) {
|
||||
func showMemberBanAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, _ title: String, message: String?, actions: [LinkBanAction]) {
|
||||
if mem.memberStatus == .memInvited {
|
||||
showAlert(title, message: message) {
|
||||
actions.map { a in
|
||||
UIAlertAction(title: a.title, style: .destructive) { _ in a.action(nil) }
|
||||
} + [cancelAlertAction]
|
||||
}
|
||||
} else {
|
||||
showAppSheet(detents: [.medium(), .large()]) {
|
||||
LinkBanDialog(
|
||||
title: title,
|
||||
message: message,
|
||||
banLabel: groupInfo.businessChat == nil ? "Ban from joining" : "Ban from connecting",
|
||||
ban: .day,
|
||||
actions: actions
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, notice: LinkNotice? = nil, dismiss: DismissAction?) {
|
||||
Task {
|
||||
do {
|
||||
let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages)
|
||||
let (updatedGroupInfo, updatedMembers) = try await apiRemoveMembers(groupInfo.groupId, [mem.groupMemberId], withMessages, notice: notice)
|
||||
await MainActor.run {
|
||||
ChatModel.shared.updateGroup(updatedGroupInfo)
|
||||
updatedMembers.forEach { updatedMember in
|
||||
@@ -1069,6 +1087,128 @@ func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool
|
||||
}
|
||||
}
|
||||
|
||||
enum LinkBan: Identifiable, Hashable {
|
||||
case no
|
||||
case hour
|
||||
case day
|
||||
case week
|
||||
case month
|
||||
case permanent
|
||||
|
||||
static let values: [LinkBan] = [.no, .hour, .day, .week, .month, .permanent]
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
var text: String {
|
||||
switch self {
|
||||
case .no: NSLocalizedString("No", comment: "ban duration")
|
||||
case .hour: NSLocalizedString("1 hour", comment: "ban duration")
|
||||
case .day: NSLocalizedString("1 day", comment: "ban duration")
|
||||
case .week: NSLocalizedString("1 week", comment: "ban duration")
|
||||
case .month: NSLocalizedString("1 month", comment: "ban duration")
|
||||
case .permanent: NSLocalizedString("Permanently", comment: "ban duration")
|
||||
}
|
||||
}
|
||||
|
||||
func notice(_ reason: ReportReason) -> LinkNotice? {
|
||||
switch self {
|
||||
case .no: nil
|
||||
case .hour: LinkNotice(ttl: 3600, reason: reason)
|
||||
case .day: LinkNotice(ttl: 86400, reason: reason)
|
||||
case .week: LinkNotice(ttl: 604800, reason: reason)
|
||||
case .month: LinkNotice(ttl: 2592000, reason: reason)
|
||||
case .permanent: LinkNotice(ttl: nil, reason: reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkBanAction {
|
||||
var title: String
|
||||
var action: (LinkNotice?) -> Void
|
||||
}
|
||||
|
||||
@ViewBuilder func linkBanRows(_ label: LocalizedStringKey, _ ban: Binding<LinkBan>, _ reason: Binding<ReportReason>) -> some View {
|
||||
WrappedPicker(label, selection: ban) {
|
||||
ForEach(LinkBan.values) { Text($0.text) }
|
||||
}
|
||||
if ban.wrappedValue != .no {
|
||||
WrappedPicker("Reason", selection: reason) {
|
||||
ForEach(ReportReason.supportedReasons, id: \.self) { Text($0.text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LinkBanDialog: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
var title: String
|
||||
var message: String?
|
||||
var banLabel: LocalizedStringKey
|
||||
@State var ban: LinkBan
|
||||
var actions: [LinkBanAction]
|
||||
@State private var reason: ReportReason = .spam
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
Section {
|
||||
linkBanRows(banLabel, $ban, $reason)
|
||||
ForEach(actions.indices, id: \.self) { i in
|
||||
Button(actions[i].title, role: .destructive) {
|
||||
dismiss()
|
||||
actions[i].action(ban.notice(reason))
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) { dismiss() }
|
||||
} footer: {
|
||||
if let message {
|
||||
Text(message).foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(title)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func showDeleteBusinessChatAlert(_ chat: Chat, deleted: (() -> Void)? = nil) {
|
||||
showAppSheet(detents: [.medium(), .large()]) {
|
||||
LinkBanDialog(
|
||||
title: NSLocalizedString("Delete chat?", comment: "alert title"),
|
||||
message: chat.chatInfo.displayName + "\n\n" + NSLocalizedString("Chat will be deleted for all members - this cannot be undone!", comment: "alert message"),
|
||||
banLabel: "Ban from connecting",
|
||||
ban: .no,
|
||||
actions: [
|
||||
LinkBanAction(title: NSLocalizedString("Delete", comment: "alert action")) { notice in
|
||||
Task {
|
||||
do {
|
||||
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notice: notice)
|
||||
await MainActor.run {
|
||||
deleted?()
|
||||
ChatModel.shared.removeChat(chat.chatInfo.id)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("showDeleteBusinessChatAlert apiDeleteChat error: \(responseError(error))")
|
||||
await MainActor.run {
|
||||
showAlert(
|
||||
NSLocalizedString("Error deleting chat!", comment: "alert title"),
|
||||
message: responseError(error)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func canBanOnChatDeletion(_ groupInfo: GroupInfo) -> Bool {
|
||||
groupInfo.businessChat?.chatType == .customer && groupInfo.membership.memberCurrent
|
||||
}
|
||||
|
||||
func deleteGroupAlertMessage(_ groupInfo: GroupInfo) -> Text {
|
||||
groupInfo.useRelays ? (
|
||||
groupInfo.membership.memberCurrent
|
||||
|
||||
@@ -446,7 +446,7 @@ struct ChatListNavLink: View {
|
||||
|
||||
if canShowGroupDelete, let gi = groupInfo, gi.canDelete {
|
||||
buttons.append(.destructive(Text("Delete")) {
|
||||
AlertManager.shared.showAlert(deleteGroupAlert(gi))
|
||||
showDeleteGroupAlert(gi)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -484,13 +484,23 @@ struct ChatListNavLink: View {
|
||||
|
||||
private func deleteGroupChatButton(_ groupInfo: GroupInfo) -> some View {
|
||||
Button {
|
||||
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
||||
showDeleteGroupAlert(groupInfo)
|
||||
} label: {
|
||||
deleteLabel
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
|
||||
private func showDeleteGroupAlert(_ groupInfo: GroupInfo) {
|
||||
if canBanOnChatDeletion(groupInfo) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
||||
showDeleteBusinessChatAlert(chat)
|
||||
}
|
||||
} else {
|
||||
AlertManager.shared.showAlert(deleteGroupAlert(groupInfo))
|
||||
}
|
||||
}
|
||||
|
||||
// Spec: spec/client/chat-list.md#contactRequestNavLink
|
||||
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
|
||||
ContactRequestView(contactRequest: contactRequest, chat: chat)
|
||||
|
||||
@@ -43,7 +43,7 @@ struct ContactListNavLink: View {
|
||||
.sheet(item: $sheet) {
|
||||
if #available(iOS 16.0, *) {
|
||||
$0.content
|
||||
.presentationDetents([.fraction(0.4)])
|
||||
.presentationDetents([.fraction($0.fraction)])
|
||||
} else {
|
||||
$0.content
|
||||
}
|
||||
|
||||
@@ -31,13 +31,17 @@ private struct PrivacySensitive: ViewModifier {
|
||||
|
||||
// Presented from the top view controller instead of a .sheet on a parent view, so an alert button or
|
||||
// a view that is itself in a sheet can open it.
|
||||
func showAppSheet<Content: View>(@ViewBuilder content: () -> Content) {
|
||||
func showAppSheet<Content: View>(detents: [UISheetPresentationController.Detent]? = nil, @ViewBuilder content: () -> Content) {
|
||||
if let topController = getTopViewController() {
|
||||
let v = content()
|
||||
.modifier(PrivacySensitive())
|
||||
.environmentObject(ChatModel.shared)
|
||||
.environmentObject(AppTheme.shared)
|
||||
topController.present(UIHostingController(rootView: v), animated: true)
|
||||
let controller = UIHostingController(rootView: v)
|
||||
if let detents, let sheet = controller.sheetPresentationController {
|
||||
sheet.detents = detents
|
||||
}
|
||||
topController.present(controller, animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -920,6 +920,46 @@ private func showInvitationLinkConnectingAlert(cleanup: (() -> Void)?) {
|
||||
)
|
||||
}
|
||||
|
||||
enum LinkNoticeTarget {
|
||||
case group
|
||||
case channel
|
||||
case address
|
||||
case link
|
||||
}
|
||||
|
||||
func showLinkNoticeAlert(_ target: LinkNoticeTarget, expiresAt: Date?, reason: ReportReason?, cleanup: (() -> Void)? = nil) {
|
||||
let join = target == .group || target == .channel
|
||||
let banned = switch target {
|
||||
case .group: NSLocalizedString("You were banned from this group.", comment: "alert message")
|
||||
case .channel: NSLocalizedString("You were banned from this channel.", comment: "alert message")
|
||||
case .address: NSLocalizedString("You were banned from connecting via this address.", comment: "alert message")
|
||||
case .link: NSLocalizedString("You were banned from connecting via this link.", comment: "alert message")
|
||||
}
|
||||
let until =
|
||||
if let expiresAt {
|
||||
String.localizedStringWithFormat(
|
||||
join
|
||||
? NSLocalizedString("You can join again after %@.", comment: "alert message")
|
||||
: NSLocalizedString("You can connect again after %@.", comment: "alert message"),
|
||||
expiresAt.formatted(date: .abbreviated, time: .shortened)
|
||||
)
|
||||
} else {
|
||||
join
|
||||
? NSLocalizedString("You can't join it again.", comment: "alert message")
|
||||
: NSLocalizedString("You can't connect again.", comment: "alert message")
|
||||
}
|
||||
let reasonLine: String? =
|
||||
if case .some(.unknown) = reason { nil }
|
||||
else { reason.map { String.localizedStringWithFormat(NSLocalizedString("Reason: %@.", comment: "alert message"), $0.text) } }
|
||||
showAlert(
|
||||
NSLocalizedString("Banned", comment: "alert title"),
|
||||
message: [banned, until, reasonLine].compactMap { $0 }.joined(separator: "\n"),
|
||||
actions: {[
|
||||
okCleanupAlertAction(cleanup: cleanup)
|
||||
]}
|
||||
)
|
||||
}
|
||||
|
||||
private func showGroupLinkConnectingAlert(groupInfo: GroupInfo?, cleanup: (() -> Void)?) {
|
||||
if let groupInfo = groupInfo {
|
||||
if groupInfo.businessChat == nil {
|
||||
@@ -1519,6 +1559,11 @@ func planAndConnect(
|
||||
cleanup: cleanup
|
||||
)
|
||||
}
|
||||
case let .linkNotice(expiresAt, reason):
|
||||
logger.debug("planAndConnect, .contactAddress, .linkNotice")
|
||||
await MainActor.run {
|
||||
showLinkNoticeAlert(.address, expiresAt: expiresAt, reason: reason, cleanup: cleanup)
|
||||
}
|
||||
}
|
||||
case let .groupLink(glp):
|
||||
switch glp {
|
||||
@@ -1649,6 +1694,14 @@ func planAndConnect(
|
||||
cleanup?()
|
||||
}
|
||||
}
|
||||
case let .linkNotice(expiresAt, reason):
|
||||
logger.debug("planAndConnect, .groupLink, .linkNotice")
|
||||
let isChannel =
|
||||
if case .link(_, .channel, _) = strConnectTarget(shortOrFullLink) { true }
|
||||
else { planSimplexName?.nameType == .publicGroup }
|
||||
await MainActor.run {
|
||||
showLinkNoticeAlert(isChannel ? .channel : .group, expiresAt: expiresAt, reason: reason, cleanup: cleanup)
|
||||
}
|
||||
}
|
||||
case let .error(chatError):
|
||||
logger.debug("planAndConnect, .error \(chatErrorString(chatError))")
|
||||
|
||||
@@ -767,6 +767,7 @@ public enum ChatErrorType: Decodable, Hashable {
|
||||
case unsupportedConnReq
|
||||
case invalidChatMessage(connection: Connection, message: String)
|
||||
case connReqMessageProhibited
|
||||
case linkNotice(expiresAt: Date?, reason: ReportReason?)
|
||||
case contactNotReady(contact: Contact)
|
||||
case contactNotActive(contact: Contact)
|
||||
case contactDisabled(contact: Contact)
|
||||
|
||||
@@ -2607,6 +2607,7 @@ public struct Connection: Decodable, Hashable {
|
||||
public var peerChatVRange: VersionRange
|
||||
public var connStatus: ConnStatus
|
||||
public var connLevel: Int
|
||||
public var viaUserContactLink: Int64?
|
||||
public var viaGroupLink: Bool
|
||||
public var customUserProfileId: Int64?
|
||||
public var connectionCode: SecurityCode?
|
||||
@@ -2620,7 +2621,7 @@ public struct Connection: Decodable, Hashable {
|
||||
public var connectionStats: ConnectionStats? = nil
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter
|
||||
case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaUserContactLink, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter
|
||||
}
|
||||
|
||||
public var id: ChatId { get { ":\(connId)" } }
|
||||
@@ -5776,18 +5777,29 @@ public enum FormatColor: String, Decodable, Hashable {
|
||||
|
||||
public enum ReportReason: Hashable {
|
||||
case spam
|
||||
case illegal
|
||||
case content
|
||||
case community
|
||||
case profile
|
||||
case other
|
||||
case unknown(type: String)
|
||||
|
||||
public static var supportedReasons: [ReportReason] = [.spam, .illegal, .community, .profile, .other]
|
||||
public static var supportedReasons: [ReportReason] = [.spam, .content, .community, .profile, .other]
|
||||
|
||||
public var rawValue: String {
|
||||
switch self {
|
||||
case .spam: "spam"
|
||||
case .content: "content"
|
||||
case .community: "community"
|
||||
case .profile: "profile"
|
||||
case .other: "other"
|
||||
case let .unknown(type): type
|
||||
}
|
||||
}
|
||||
|
||||
public var text: String {
|
||||
switch self {
|
||||
case .spam: return NSLocalizedString("Spam", comment: "report reason")
|
||||
case .illegal: return NSLocalizedString("Inappropriate content", comment: "report reason")
|
||||
case .content: return NSLocalizedString("Inappropriate content", comment: "report reason")
|
||||
case .community: return NSLocalizedString("Community guidelines violation", comment: "report reason")
|
||||
case .profile: return NSLocalizedString("Inappropriate profile", comment: "report reason")
|
||||
case .other: return NSLocalizedString("Another reason", comment: "report reason")
|
||||
@@ -5807,14 +5819,7 @@ public enum ReportReason: Hashable {
|
||||
extension ReportReason: Encodable {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .spam: try container.encode("spam")
|
||||
case .illegal: try container.encode("illegal")
|
||||
case .community: try container.encode("community")
|
||||
case .profile: try container.encode("profile")
|
||||
case .other: try container.encode("other")
|
||||
case let .unknown(type): try container.encode(type)
|
||||
}
|
||||
try container.encode(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5824,7 +5829,7 @@ extension ReportReason: Decodable {
|
||||
let type = try container.decode(String.self)
|
||||
switch type {
|
||||
case "spam": self = .spam
|
||||
case "illegal": self = .illegal
|
||||
case "content", "illegal": self = .content
|
||||
case "community": self = .community
|
||||
case "profile": self = .profile
|
||||
case "other": self = .other
|
||||
|
||||
+6
-5
@@ -2038,6 +2038,7 @@ data class Connection(
|
||||
val peerChatVRange: VersionRange,
|
||||
val connStatus: ConnStatus,
|
||||
val connLevel: Int,
|
||||
val viaUserContactLink: Long? = null,
|
||||
val viaGroupLink: Boolean,
|
||||
val customUserProfileId: Long? = null,
|
||||
val connectionCode: SecurityCode? = null,
|
||||
@@ -5293,19 +5294,19 @@ enum class FormatColor(val color: String) {
|
||||
@Serializable(with = ReportReasonSerializer::class)
|
||||
sealed class ReportReason {
|
||||
@Serializable @SerialName("spam") object Spam: ReportReason()
|
||||
@Serializable @SerialName("illegal") object Illegal: ReportReason()
|
||||
@Serializable @SerialName("content") object Content: ReportReason()
|
||||
@Serializable @SerialName("community") object Community: ReportReason()
|
||||
@Serializable @SerialName("profile") object Profile: ReportReason()
|
||||
@Serializable @SerialName("other") object Other: ReportReason()
|
||||
@Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason()
|
||||
|
||||
companion object {
|
||||
val supportedReasons: List<ReportReason> = listOf(Spam, Illegal, Community, Profile, Other)
|
||||
val supportedReasons: List<ReportReason> = listOf(Spam, Content, Community, Profile, Other)
|
||||
}
|
||||
|
||||
val text: String get() = when (this) {
|
||||
Spam -> generalGetString(MR.strings.report_reason_spam)
|
||||
Illegal -> generalGetString(MR.strings.report_reason_illegal)
|
||||
Content -> generalGetString(MR.strings.report_reason_illegal)
|
||||
Community -> generalGetString(MR.strings.report_reason_community)
|
||||
Profile -> generalGetString(MR.strings.report_reason_profile)
|
||||
Other -> generalGetString(MR.strings.report_reason_other)
|
||||
@@ -5320,7 +5321,7 @@ object ReportReasonSerializer : KSerializer<ReportReason> {
|
||||
override fun deserialize(decoder: Decoder): ReportReason {
|
||||
return when (val value = decoder.decodeString()) {
|
||||
"spam" -> ReportReason.Spam
|
||||
"illegal" -> ReportReason.Illegal
|
||||
"content", "illegal" -> ReportReason.Content
|
||||
"community" -> ReportReason.Community
|
||||
"profile" -> ReportReason.Profile
|
||||
"other" -> ReportReason.Other
|
||||
@@ -5331,7 +5332,7 @@ object ReportReasonSerializer : KSerializer<ReportReason> {
|
||||
override fun serialize(encoder: Encoder, value: ReportReason) {
|
||||
val stringValue = when (value) {
|
||||
is ReportReason.Spam -> "spam"
|
||||
is ReportReason.Illegal -> "illegal"
|
||||
is ReportReason.Content -> "content"
|
||||
is ReportReason.Community -> "community"
|
||||
is ReportReason.Profile -> "profile"
|
||||
is ReportReason.Other -> "other"
|
||||
|
||||
+34
-17
@@ -33,6 +33,8 @@ import chat.simplex.common.views.chat.item.showContentBlockedAlert
|
||||
import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
|
||||
import chat.simplex.common.views.chatlist.openGroupChat
|
||||
import chat.simplex.common.views.migration.MigrationFileLinkData
|
||||
import chat.simplex.common.views.newchat.LinkNoticeTarget
|
||||
import chat.simplex.common.views.newchat.showLinkNoticeAlert
|
||||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.defaultConditionsLink
|
||||
@@ -1656,8 +1658,12 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
private fun apiConnectResponseAlert(r: API) {
|
||||
private fun apiConnectResponseAlert(r: API, noticeTarget: LinkNoticeTarget = LinkNoticeTarget.Link) {
|
||||
when {
|
||||
r is API.Error && r.err is ChatError.ChatErrorChat
|
||||
&& r.err.errorType is ChatErrorType.LinkNotice -> {
|
||||
showLinkNoticeAlert(noticeTarget, r.err.errorType.expiresAt, r.err.errorType.reason)
|
||||
}
|
||||
r is API.Error && r.err is ChatError.ChatErrorChat
|
||||
&& r.err.errorType is ChatErrorType.InvalidConnReq -> {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
@@ -1818,17 +1824,17 @@ object ChatController {
|
||||
if (r is API.Result && r.res is CR.StartedConnectionToContact) return r.res.contact
|
||||
if (r != null) {
|
||||
Log.e(TAG, "apiConnectPreparedContact bad response: ${r.responseType} ${r.details}")
|
||||
apiConnectResponseAlert(r)
|
||||
apiConnectResponseAlert(r, LinkNoticeTarget.Address)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, incognito: Boolean, msg: MsgContent?): Pair<GroupInfo, List<RelayConnectionResult>>? {
|
||||
suspend fun apiConnectPreparedGroup(rh: Long?, groupId: Long, isChannel: Boolean, incognito: Boolean, msg: MsgContent?): Pair<GroupInfo, List<RelayConnectionResult>>? {
|
||||
val r = sendCmdWithRetry(rh, CC.APIConnectPreparedGroup(groupId, incognito, msg))
|
||||
if (r is API.Result && r.res is CR.StartedConnectionToGroup) return Pair(r.res.groupInfo, r.res.relayResults)
|
||||
if (r != null) {
|
||||
Log.e(TAG, "apiConnectPreparedGroup bad response: ${r.responseType} ${r.details}")
|
||||
apiConnectResponseAlert(r)
|
||||
apiConnectResponseAlert(r, if (isChannel) LinkNoticeTarget.Channel else LinkNoticeTarget.Group)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -1838,24 +1844,26 @@ object ChatController {
|
||||
val r = sendCmdWithRetry(rh, CC.ApiConnectContactViaAddress(userId, incognito, contactId))
|
||||
if (r is API.Result && r.res is CR.SentInvitationToContact) return r.res.contact
|
||||
if (r == null) return null
|
||||
if (!(networkErrorAlert(r))) {
|
||||
if (r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.LinkNotice) {
|
||||
showLinkNoticeAlert(LinkNoticeTarget.Address, r.err.errorType.expiresAt, r.err.errorType.reason)
|
||||
} else if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiConnectContactViaAddress", generalGetString(MR.strings.connection_error), r)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
|
||||
suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null) {
|
||||
val cInfo = chat.chatInfo
|
||||
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) {
|
||||
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode, notice = notice)) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatsContext.removeChat(chat.remoteHostId, cInfo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean {
|
||||
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null): Boolean {
|
||||
chatModel.deletedChats.value += rh to type.type + id
|
||||
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
|
||||
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode, notice))
|
||||
val res = r.result
|
||||
val success = when {
|
||||
res is CR.ContactDeleted && type == ChatType.Direct -> true
|
||||
@@ -1877,10 +1885,10 @@ object ChatController {
|
||||
return success
|
||||
}
|
||||
|
||||
suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? {
|
||||
suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null): Contact? {
|
||||
val type = ChatType.Direct
|
||||
chatModel.deletedChats.value += rh to type.type + id
|
||||
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
|
||||
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode, notice))
|
||||
val contact = if (r is API.Result && r.res is CR.ContactDeleted) {
|
||||
r.res.contact
|
||||
} else {
|
||||
@@ -2449,8 +2457,8 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List<Long>, withMessages: Boolean): Pair<GroupInfo, List<GroupMember>>? {
|
||||
val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages))
|
||||
suspend fun apiRemoveMembers(rh: Long?, groupId: Long, memberIds: List<Long>, withMessages: Boolean, notice: LinkNotice? = null): Pair<GroupInfo, List<GroupMember>>? {
|
||||
val r = sendCmd(rh, CC.ApiRemoveMembers(groupId, memberIds, withMessages, notice))
|
||||
if (r is API.Result && r.res is CR.UserDeletedMembers) return r.res.groupInfo to r.res.members
|
||||
if (!(networkErrorAlert(r))) {
|
||||
apiErrorAlert("apiRemoveMembers", generalGetString(MR.strings.error_removing_member), r)
|
||||
@@ -3949,7 +3957,7 @@ sealed class CC {
|
||||
class ApiDeleteMemberSupportChat(val groupId: Long, val groupMemberId: Long): CC()
|
||||
class ApiMembersRole(val groupId: Long, val memberIds: List<Long>, val memberRole: GroupMemberRole): CC()
|
||||
class ApiBlockMembersForAll(val groupId: Long, val memberIds: List<Long>, val blocked: Boolean): CC()
|
||||
class ApiRemoveMembers(val groupId: Long, val memberIds: List<Long>, val withMessages: Boolean): CC()
|
||||
class ApiRemoveMembers(val groupId: Long, val memberIds: List<Long>, val withMessages: Boolean, val notice: LinkNotice?): CC()
|
||||
class ApiLeaveGroup(val groupId: Long): CC()
|
||||
class ApiListMembers(val groupId: Long): CC()
|
||||
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
|
||||
@@ -4009,7 +4017,7 @@ sealed class CC {
|
||||
class APIConnectPreparedGroup(val groupId: Long, val incognito: Boolean, val msg: MsgContent?): CC()
|
||||
class APIConnect(val userId: Long, val incognito: Boolean, val connLink: CreatedConnLink): CC()
|
||||
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
|
||||
class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC()
|
||||
class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode, val notice: LinkNotice?): CC()
|
||||
class ApiClearChat(val type: ChatType, val id: Long): CC()
|
||||
class ApiListContacts(val userId: Long): CC()
|
||||
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
|
||||
@@ -4165,7 +4173,7 @@ sealed class CC {
|
||||
is ApiDeleteMemberSupportChat -> "/_delete member chat #$groupId $groupMemberId"
|
||||
is ApiMembersRole -> "/_member role #$groupId ${memberIds.joinToString(",")} ${memberRole.memberRole}"
|
||||
is ApiBlockMembersForAll -> "/_block #$groupId ${memberIds.joinToString(",")} blocked=${onOff(blocked)}"
|
||||
is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}"
|
||||
is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}${maybeNotice(notice)}"
|
||||
is ApiLeaveGroup -> "/_leave #$groupId"
|
||||
is ApiListMembers -> "/_members #$groupId"
|
||||
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
|
||||
@@ -4228,7 +4236,7 @@ sealed class CC {
|
||||
is APIConnectPreparedGroup -> "/_connect group #$groupId incognito=${onOff(incognito)}${maybeContent(msg)}"
|
||||
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} ${connLink.connFullLink} ${connLink.connShortLink ?: ""}"
|
||||
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
|
||||
is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}"
|
||||
is ApiDeleteChat -> "/_delete ${chatRef(type, id, scope = null)} ${chatDeleteMode.cmdString}${maybeNotice(notice)}"
|
||||
is ApiClearChat -> "/_clear chat ${chatRef(type, id, scope = null)}"
|
||||
is ApiListContacts -> "/_contacts $userId"
|
||||
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
|
||||
@@ -4521,6 +4529,8 @@ sealed class CC {
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeNotice(notice: LinkNotice?): String = if (notice == null) "" else " notice=" + json.encodeToString(notice)
|
||||
|
||||
companion object {
|
||||
fun chatRef(chatType: ChatType, id: Long, scope: GroupChatScope?) = when (scope) {
|
||||
null -> "${chatType.type}${id}"
|
||||
@@ -7294,6 +7304,9 @@ sealed class ChatDeleteMode {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LinkNotice(val ttl: Long? = null, val reason: ReportReason? = null)
|
||||
|
||||
@Serializable
|
||||
data class CreatedConnLink(val connFullLink: String, val connShortLink: String?) {
|
||||
fun simplexChatUri(short: Boolean): String =
|
||||
@@ -7462,6 +7475,7 @@ sealed class ContactAddressPlan {
|
||||
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
|
||||
@Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan()
|
||||
@Serializable @SerialName("contactViaAddress") class ContactViaAddress(val contact: Contact): ContactAddressPlan()
|
||||
@Serializable @SerialName("linkNotice") class LinkNotice(val expiresAt: Instant? = null, val reason: ReportReason? = null): ContactAddressPlan()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -7473,6 +7487,7 @@ sealed class GroupLinkPlan {
|
||||
@Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan()
|
||||
@Serializable @SerialName("noRelays") class NoRelays(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan()
|
||||
@Serializable @SerialName("updateRequired") class UpdateRequired(val groupSLinkData_: GroupShortLinkData? = null): GroupLinkPlan()
|
||||
@Serializable @SerialName("linkNotice") class LinkNotice(val expiresAt: Instant? = null, val reason: ReportReason? = null): GroupLinkPlan()
|
||||
}
|
||||
|
||||
abstract class TerminalItem {
|
||||
@@ -7780,6 +7795,7 @@ sealed class ChatErrorType {
|
||||
is UnsupportedConnReq -> "unsupportedConnReq"
|
||||
is InvalidChatMessage -> "invalidChatMessage"
|
||||
is ConnReqMessageProhibited -> "connReqMessageProhibited"
|
||||
is LinkNotice -> "linkNotice"
|
||||
is ContactNotReady -> "contactNotReady"
|
||||
is ContactNotActive -> "contactNotActive"
|
||||
is ContactDisabled -> "contactDisabled"
|
||||
@@ -7865,6 +7881,7 @@ sealed class ChatErrorType {
|
||||
@Serializable @SerialName("unsupportedConnReq") object UnsupportedConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
|
||||
@Serializable @SerialName("connReqMessageProhibited") object ConnReqMessageProhibited: ChatErrorType()
|
||||
@Serializable @SerialName("linkNotice") class LinkNotice(val expiresAt: Instant? = null, val reason: ReportReason? = null): ChatErrorType()
|
||||
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType()
|
||||
@Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType()
|
||||
|
||||
+11
-4
@@ -40,6 +40,8 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.group.ChatTTLOption
|
||||
import chat.simplex.common.views.chat.group.LinkBan
|
||||
import chat.simplex.common.views.chat.group.LinkBanRows
|
||||
import chat.simplex.common.views.chat.item.MarkdownText
|
||||
import chat.simplex.common.views.chatlist.updateChatSettings
|
||||
import chat.simplex.common.views.newchat.*
|
||||
@@ -305,6 +307,8 @@ sealed class ContactDeleteMode {
|
||||
|
||||
private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
val contactDeleteMode = mutableStateOf<ContactDeleteMode>(ContactDeleteMode.Full())
|
||||
val ban = mutableStateOf(LinkBan.No)
|
||||
val reason = mutableStateOf<ReportReason>(ReportReason.Spam)
|
||||
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.delete_contact_question),
|
||||
@@ -330,10 +334,13 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C
|
||||
)
|
||||
}
|
||||
}
|
||||
if (contact.activeConn?.viaUserContactLink != null) {
|
||||
LinkBanRows(stringResource(MR.strings.link_ban_from_connecting), ban, reason)
|
||||
}
|
||||
// Delete without notification
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = false))
|
||||
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = false), notice = ban.value.notice(reason.value))
|
||||
if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
|
||||
showDeleteContactNotice(contact)
|
||||
}
|
||||
@@ -343,7 +350,7 @@ private fun deleteActiveContactDialog(chat: Chat, contact: Contact, chatModel: C
|
||||
// Delete contact and notify
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = true))
|
||||
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.value.toChatDeleteMode(notify = true), notice = ban.value.notice(reason.value))
|
||||
if (contactDeleteMode.value is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
|
||||
showDeleteContactNotice(contact)
|
||||
}
|
||||
@@ -468,11 +475,11 @@ private fun showDeleteContactNotice(contact: Contact) {
|
||||
)
|
||||
}
|
||||
|
||||
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
|
||||
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true), notice: LinkNotice? = null) {
|
||||
val chatInfo = chat.chatInfo
|
||||
withBGApi {
|
||||
val chatRh = chat.remoteHostId
|
||||
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode)
|
||||
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode, notice)
|
||||
if (ct != null) {
|
||||
withContext(Dispatchers.Main) {
|
||||
when (chatDeleteMode) {
|
||||
|
||||
+11
-6
@@ -13,8 +13,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import chat.simplex.common.views.chat.group.LinkBanAction
|
||||
import chat.simplex.common.views.chat.group.removeMember
|
||||
import chat.simplex.common.views.chat.group.removeMemberDialog
|
||||
import chat.simplex.common.views.chat.group.showMemberBanAlert
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
@@ -71,13 +73,16 @@ fun ComposeContextPendingMemberActionsView(
|
||||
}
|
||||
|
||||
fun rejectMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
showMemberBanAlert(
|
||||
groupInfo,
|
||||
showBan = true,
|
||||
title = generalGetString(MR.strings.reject_pending_member_alert_title),
|
||||
confirmText = generalGetString(MR.strings.reject_pending_member_button),
|
||||
onConfirm = {
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
|
||||
},
|
||||
destructive = true,
|
||||
text = null,
|
||||
actions = listOf(
|
||||
LinkBanAction(generalGetString(MR.strings.reject_pending_member_button)) { notice ->
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close, notice)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -697,6 +697,7 @@ fun ComposeView(
|
||||
val result = chatModel.controller.apiConnectPreparedGroup(
|
||||
rh = chat.remoteHostId,
|
||||
groupId = chat.chatInfo.apiId,
|
||||
isChannel = chat.chatInfo.isChannel,
|
||||
incognito = incognito,
|
||||
msg = mc
|
||||
)
|
||||
@@ -1238,7 +1239,7 @@ fun ComposeView(
|
||||
fun ReportReasonView(reason: ReportReason) {
|
||||
val reportText = when (reason) {
|
||||
is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam)
|
||||
is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal)
|
||||
is ReportReason.Content -> generalGetString(MR.strings.report_compose_reason_header_illegal)
|
||||
is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile)
|
||||
is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community)
|
||||
is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other)
|
||||
|
||||
+128
-87
@@ -221,29 +221,41 @@ fun deleteGroupDialog(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, cl
|
||||
if (groupInfo.membership.memberCurrent) MR.strings.delete_chat_for_all_members_cannot_undo_warning
|
||||
else MR.strings.delete_chat_for_self_cannot_undo_warning
|
||||
}
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(titleId),
|
||||
text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}",
|
||||
parseHtml = false,
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId)
|
||||
if (r) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatsContext.removeChat(chat.remoteHostId, chatInfo.id)
|
||||
if (chatModel.chatId.value == chatInfo.id) {
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
val text = "${groupInfo.displayName}\n\n${generalGetString(messageId)}"
|
||||
val deleteGroup: (LinkNotice?) -> Unit = { notice ->
|
||||
withBGApi {
|
||||
val r = chatModel.controller.apiDeleteChat(chat.remoteHostId, chatInfo.chatType, chatInfo.apiId, notice = notice)
|
||||
if (r) {
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatsContext.removeChat(chat.remoteHostId, chatInfo.id)
|
||||
if (chatModel.chatId.value == chatInfo.id) {
|
||||
chatModel.chatId.value = null
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (canBanOnChatDeletion(groupInfo)) {
|
||||
showLinkBanAlert(
|
||||
generalGetString(titleId),
|
||||
text,
|
||||
generalGetString(MR.strings.link_ban_from_connecting),
|
||||
LinkBan.No,
|
||||
listOf(LinkBanAction(generalGetString(MR.strings.delete_verb), deleteGroup))
|
||||
)
|
||||
} else {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(titleId),
|
||||
text = text,
|
||||
parseHtml = false,
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
onConfirm = { deleteGroup(null) },
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
@@ -297,61 +309,25 @@ fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (groupInfo.useRelays) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_subscriber_question),
|
||||
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
val titleId = MR.strings.button_remove_member_question
|
||||
val messageId = if (groupInfo.businessChat == null)
|
||||
MR.strings.member_will_be_removed_from_group_cannot_be_undone
|
||||
else
|
||||
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(titleId),
|
||||
generalGetString(messageId),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
showMemberBanAlert(
|
||||
groupInfo,
|
||||
showBan = mem.memberStatus != GroupMemberStatus.MemInvited,
|
||||
title = generalGetString(if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question else MR.strings.button_remove_member_question),
|
||||
text = generalGetString(
|
||||
if (groupInfo.useRelays) MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone
|
||||
else if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone
|
||||
else MR.strings.member_will_be_removed_from_chat_cannot_be_undone
|
||||
),
|
||||
actions = listOf(
|
||||
LinkBanAction(generalGetString(MR.strings.remove_member_confirmation)) { notice ->
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false, notice = notice)
|
||||
},
|
||||
LinkBanAction(generalGetString(MR.strings.remove_member_delete_messages_confirmation)) { notice ->
|
||||
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true, notice = notice)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,22 +336,75 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis
|
||||
MR.strings.members_will_be_removed_from_group_cannot_be_undone
|
||||
else
|
||||
MR.strings.members_will_be_removed_from_chat_cannot_be_undone
|
||||
showMemberBanAlert(
|
||||
groupInfo,
|
||||
showBan = true,
|
||||
title = generalGetString(MR.strings.button_remove_members_question),
|
||||
text = generalGetString(messageId),
|
||||
actions = listOf(
|
||||
LinkBanAction(generalGetString(MR.strings.remove_member_confirmation)) { notice ->
|
||||
removeMembers(rhId, groupInfo, memberIds, withMessages = false, notice = notice, onSuccess = onSuccess)
|
||||
},
|
||||
LinkBanAction(generalGetString(MR.strings.remove_member_delete_messages_confirmation)) { notice ->
|
||||
removeMembers(rhId, groupInfo, memberIds, withMessages = true, notice = notice, onSuccess = onSuccess)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
enum class LinkBan {
|
||||
No, Hour, Day, Week, Month, Permanent;
|
||||
|
||||
val text: String get() = generalGetString(
|
||||
when (this) {
|
||||
No -> MR.strings.link_ban_no
|
||||
Hour -> MR.strings.link_ban_1_hour
|
||||
Day -> MR.strings.link_ban_1_day
|
||||
Week -> MR.strings.link_ban_1_week
|
||||
Month -> MR.strings.link_ban_1_month
|
||||
Permanent -> MR.strings.link_ban_permanently
|
||||
}
|
||||
)
|
||||
|
||||
fun notice(reason: ReportReason): LinkNotice? = when (this) {
|
||||
No -> null
|
||||
Hour -> LinkNotice(ttl = 3600, reason = reason)
|
||||
Day -> LinkNotice(ttl = 86400, reason = reason)
|
||||
Week -> LinkNotice(ttl = 604800, reason = reason)
|
||||
Month -> LinkNotice(ttl = 2592000, reason = reason)
|
||||
Permanent -> LinkNotice(ttl = null, reason = reason)
|
||||
}
|
||||
}
|
||||
|
||||
class LinkBanAction(val title: String, val action: (LinkNotice?) -> Unit)
|
||||
|
||||
@Composable
|
||||
fun LinkBanRows(label: String, ban: MutableState<LinkBan>, reason: MutableState<ReportReason>) {
|
||||
ExposedDropDownSettingRow(label, LinkBan.entries.map { it to it.text }, ban, onSelected = { ban.value = it })
|
||||
if (ban.value != LinkBan.No) {
|
||||
ExposedDropDownSettingRow(generalGetString(MR.strings.link_ban_reason), ReportReason.supportedReasons.map { it to it.text }, reason, onSelected = { reason.value = it })
|
||||
}
|
||||
}
|
||||
|
||||
fun showLinkBanAlert(title: String, text: String?, banLabel: String?, ban: LinkBan, actions: List<LinkBanAction>) {
|
||||
val banState = mutableStateOf(ban)
|
||||
val reason = mutableStateOf<ReportReason>(ReportReason.Spam)
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_members_question),
|
||||
generalGetString(messageId),
|
||||
title,
|
||||
text,
|
||||
parseHtml = false,
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, memberIds, withMessages = false, onSuccess = onSuccess)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
if (banLabel != null) {
|
||||
LinkBanRows(banLabel, banState, reason)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMembers(rhId, groupInfo, memberIds, withMessages = true, onSuccess = onSuccess)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
actions.forEach { a ->
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
a.action(banState.value.notice(reason.value))
|
||||
}) {
|
||||
Text(a.title, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
@@ -386,6 +415,18 @@ private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: Lis
|
||||
})
|
||||
}
|
||||
|
||||
fun showMemberBanAlert(groupInfo: GroupInfo, showBan: Boolean, title: String, text: String?, actions: List<LinkBanAction>) {
|
||||
if (showBan) {
|
||||
val banLabel = generalGetString(if (groupInfo.businessChat == null) MR.strings.link_ban_from_joining else MR.strings.link_ban_from_connecting)
|
||||
showLinkBanAlert(title, text, banLabel, LinkBan.Day, actions)
|
||||
} else {
|
||||
showLinkBanAlert(title, text, null, LinkBan.No, actions)
|
||||
}
|
||||
}
|
||||
|
||||
fun canBanOnChatDeletion(groupInfo: GroupInfo): Boolean =
|
||||
groupInfo.businessChat?.chatType == BusinessChatType.Customer && groupInfo.membership.memberCurrent
|
||||
|
||||
@Composable
|
||||
fun SearchButton(
|
||||
modifier: Modifier,
|
||||
@@ -1397,9 +1438,9 @@ private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel)
|
||||
}
|
||||
}
|
||||
|
||||
fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, withMessages: Boolean, onSuccess: () -> Unit = {}) {
|
||||
fun removeMembers(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, withMessages: Boolean, notice: LinkNotice? = null, onSuccess: () -> Unit = {}) {
|
||||
withBGApi {
|
||||
val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds, withMessages = withMessages)
|
||||
val r = chatModel.controller.apiRemoveMembers(rhId, groupInfo.groupId, memberIds, withMessages = withMessages, notice = notice)
|
||||
if (r != null) {
|
||||
val (updatedGroupInfo, updatedMembers) = r
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
+19
-54
@@ -270,60 +270,25 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c
|
||||
}
|
||||
}
|
||||
})
|
||||
} else if (groupInfo.useRelays) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_subscriber_question),
|
||||
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
val messageId = if (groupInfo.businessChat == null)
|
||||
MR.strings.member_will_be_removed_from_group_cannot_be_undone
|
||||
else
|
||||
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
generalGetString(MR.strings.button_remove_member_question),
|
||||
generalGetString(messageId),
|
||||
buttons = {
|
||||
Column {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
showMemberBanAlert(
|
||||
groupInfo,
|
||||
showBan = member.memberStatus != GroupMemberStatus.MemInvited,
|
||||
title = generalGetString(if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question else MR.strings.button_remove_member_question),
|
||||
text = generalGetString(
|
||||
if (groupInfo.useRelays) MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone
|
||||
else if (groupInfo.businessChat == null) MR.strings.member_will_be_removed_from_group_cannot_be_undone
|
||||
else MR.strings.member_will_be_removed_from_chat_cannot_be_undone
|
||||
),
|
||||
actions = listOf(
|
||||
LinkBanAction(generalGetString(MR.strings.remove_member_confirmation)) { notice ->
|
||||
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close, notice)
|
||||
},
|
||||
LinkBanAction(generalGetString(MR.strings.remove_member_delete_messages_confirmation)) { notice ->
|
||||
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close, notice)
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,9 +304,9 @@ fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupM
|
||||
)
|
||||
}
|
||||
|
||||
fun removeMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, withMessages: Boolean, chatModel: ChatModel, close: (() -> Unit)? = null) {
|
||||
fun removeMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, withMessages: Boolean, chatModel: ChatModel, close: (() -> Unit)? = null, notice: LinkNotice? = null) {
|
||||
withBGApi {
|
||||
val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId), withMessages = withMessages)
|
||||
val r = chatModel.controller.apiRemoveMembers(rhId, member.groupId, listOf(member.groupMemberId), withMessages = withMessages, notice = notice)
|
||||
if (r != null) {
|
||||
val (updatedGroupInfo, removedMembers) = r
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
+39
@@ -16,6 +16,7 @@ import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Instant
|
||||
|
||||
enum class ConnectionLinkType {
|
||||
INVITATION, CONTACT, GROUP
|
||||
@@ -244,6 +245,11 @@ private suspend fun planAndConnectTask(
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
is ContactAddressPlan.LinkNotice -> {
|
||||
Log.d(TAG, "planAndConnect, .ContactAddress, .LinkNotice")
|
||||
showLinkNoticeAlert(LinkNoticeTarget.Address, connectionPlan.contactAddressPlan.expiresAt, connectionPlan.contactAddressPlan.reason)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
is ConnectionPlan.GroupLink -> when (connectionPlan.groupLinkPlan) {
|
||||
is GroupLinkPlan.Ok ->
|
||||
@@ -388,6 +394,12 @@ private suspend fun planAndConnectTask(
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
is GroupLinkPlan.LinkNotice -> {
|
||||
Log.d(TAG, "planAndConnect, .GroupLink, .LinkNotice")
|
||||
val isChannel = (target as? ConnectTarget.Link)?.linkType == SimplexLinkType.channel || planSimplexName?.nameType == SimplexNameType.publicGroup
|
||||
showLinkNoticeAlert(if (isChannel) LinkNoticeTarget.Channel else LinkNoticeTarget.Group, connectionPlan.groupLinkPlan.expiresAt, connectionPlan.groupLinkPlan.reason)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
is ConnectionPlan.Error -> {
|
||||
Log.d(TAG, "planAndConnect, error ${connectionPlan.chatError}")
|
||||
@@ -436,6 +448,33 @@ suspend fun connectViaUri(
|
||||
return pcc != null
|
||||
}
|
||||
|
||||
enum class LinkNoticeTarget {
|
||||
Group, Channel, Address, Link
|
||||
}
|
||||
|
||||
fun showLinkNoticeAlert(target: LinkNoticeTarget, expiresAt: Instant?, reason: ReportReason?) {
|
||||
val join = target == LinkNoticeTarget.Group || target == LinkNoticeTarget.Channel
|
||||
val banned = generalGetString(
|
||||
when (target) {
|
||||
LinkNoticeTarget.Group -> MR.strings.link_ban_alert_group
|
||||
LinkNoticeTarget.Channel -> MR.strings.link_ban_alert_channel
|
||||
LinkNoticeTarget.Address -> MR.strings.link_ban_alert_address
|
||||
LinkNoticeTarget.Link -> MR.strings.link_ban_alert_link
|
||||
}
|
||||
)
|
||||
val until =
|
||||
if (expiresAt != null) {
|
||||
String.format(generalGetString(if (join) MR.strings.link_ban_alert_join_after else MR.strings.link_ban_alert_connect_after), localTimestamp(expiresAt))
|
||||
} else {
|
||||
generalGetString(if (join) MR.strings.link_ban_alert_cant_join else MR.strings.link_ban_alert_cant_connect)
|
||||
}
|
||||
val reasonLine = if (reason == null || reason is ReportReason.Unknown) null else String.format(generalGetString(MR.strings.link_ban_alert_reason), reason.text)
|
||||
AlertManager.privacySensitive.showAlertMsg(
|
||||
generalGetString(MR.strings.link_ban_alert_title),
|
||||
listOfNotNull(banned, until, reasonLine).joinToString("\n")
|
||||
)
|
||||
}
|
||||
|
||||
fun planToConnectionLinkType(connectionPlan: ConnectionPlan): ConnectionLinkType? {
|
||||
return when(connectionPlan) {
|
||||
is ConnectionPlan.InvitationLink -> ConnectionLinkType.INVITATION
|
||||
|
||||
@@ -2011,6 +2011,25 @@
|
||||
<string name="remove_member_delete_messages_confirmation">Remove and delete messages</string>
|
||||
<string name="delete_member_messages_confirmation">Delete messages</string>
|
||||
<string name="remove_member_button">Remove member</string>
|
||||
<string name="link_ban_from_joining">Ban from joining</string>
|
||||
<string name="link_ban_from_connecting">Ban from connecting</string>
|
||||
<string name="link_ban_reason">Reason</string>
|
||||
<string name="link_ban_no">No</string>
|
||||
<string name="link_ban_1_hour">1 hour</string>
|
||||
<string name="link_ban_1_day">1 day</string>
|
||||
<string name="link_ban_1_week">1 week</string>
|
||||
<string name="link_ban_1_month">1 month</string>
|
||||
<string name="link_ban_permanently">Permanently</string>
|
||||
<string name="link_ban_alert_title">Banned</string>
|
||||
<string name="link_ban_alert_group">You were banned from this group.</string>
|
||||
<string name="link_ban_alert_channel">You were banned from this channel.</string>
|
||||
<string name="link_ban_alert_address">You were banned from connecting via this address.</string>
|
||||
<string name="link_ban_alert_link">You were banned from connecting via this link.</string>
|
||||
<string name="link_ban_alert_join_after">You can join again after %s.</string>
|
||||
<string name="link_ban_alert_connect_after">You can connect again after %s.</string>
|
||||
<string name="link_ban_alert_cant_join">You can\'t join it again.</string>
|
||||
<string name="link_ban_alert_cant_connect">You can\'t connect again.</string>
|
||||
<string name="link_ban_alert_reason">Reason: %s.</string>
|
||||
<string name="block_member_question">Block member?</string>
|
||||
<string name="block_member_button">Block member</string>
|
||||
<string name="block_member_confirmation">Block</string>
|
||||
|
||||
@@ -727,7 +727,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o
|
||||
rejectPendingMember rjctNotice = do
|
||||
let gmId = groupMemberId' m
|
||||
sendComposedMessages cc (SRGroup groupId (Just $ GCSMemberSupport (Just gmId)) False) [MCText rjctNotice]
|
||||
sendChatCmd cc (APIRemoveMembers groupId [gmId] False) >>= \case
|
||||
sendChatCmd cc (APIRemoveMembers groupId [gmId] False Nothing) >>= \case
|
||||
Right (CRUserDeletedMembers _ _ (_ : _) _ _) -> do
|
||||
atomically $ TM.delete gmId $ pendingCaptchas env
|
||||
logInfo $ "Member " <> viewName displayName <> " rejected, group " <> tshow groupId <> ":" <> viewGroupName g
|
||||
@@ -953,6 +953,7 @@ directoryServiceEvent opts@DirectoryOpts {adminUsers, superUsers, serviceName, o
|
||||
GLPNoRelays _ -> sendMessage cc ct $ T.toTitle gt <> " has no active relays. Please try again later."
|
||||
GLPUpdateRequired _ -> sendMessage cc ct $ T.toTitle gt <> " requires a newer version."
|
||||
GLPOwnLink _ -> sendMessage cc ct "Unexpected error. Please report it to directory admins."
|
||||
GLPLinkNotice {} -> sendMessage cc ct $ "Directory is banned from joining this " <> gt <> "."
|
||||
_ -> sendMessage cc ct "Unexpected error. Please report it to directory admins."
|
||||
|
||||
joinAndRegisterPublicGroup :: Contact -> CreatedLinkContact -> MemberId -> Text -> GroupShortLinkData -> IO ()
|
||||
|
||||
@@ -888,19 +888,20 @@ Remove members. Requires Admin role.
|
||||
- groupId: int64
|
||||
- groupMemberIds: [int64]
|
||||
- withMessages: bool
|
||||
- notice: [LinkNotice](./TYPES.md#linknotice)?
|
||||
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/_remove #<groupId> <groupMemberIds[0]>[,<groupMemberIds[1]>...][ messages=on]
|
||||
/_remove #<groupId> <groupMemberIds[0]>[,<groupMemberIds[1]>...][ messages=on][ notice=<json(notice)>]
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/_remove #' + groupId + ' ' + groupMemberIds.join(',') + (withMessages ? ' messages=on' : '') // JavaScript
|
||||
'/_remove #' + groupId + ' ' + groupMemberIds.join(',') + (withMessages ? ' messages=on' : '') + (notice ? ' notice=' + JSON.stringify(notice) : '') // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/_remove #' + str(groupId) + ' ' + ','.join(map(str, groupMemberIds)) + (' messages=on' if withMessages else '') # Python
|
||||
'/_remove #' + str(groupId) + ' ' + ','.join(map(str, groupMemberIds)) + (' messages=on' if withMessages else '') + ((' notice=' + json.dumps(notice)) if notice is not None else '') # Python
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
@@ -1857,19 +1858,20 @@ Delete chat.
|
||||
**Parameters**:
|
||||
- chatRef: [ChatRef](./TYPES.md#chatref)
|
||||
- chatDeleteMode: [ChatDeleteMode](./TYPES.md#chatdeletemode)
|
||||
- notice: [LinkNotice](./TYPES.md#linknotice)?
|
||||
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/_delete <str(chatRef)> <str(chatDeleteMode)>
|
||||
/_delete <str(chatRef)> <str(chatDeleteMode)>[ notice=<json(notice)>]
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/_delete ' + ChatRef.cmdString(chatRef) + ' ' + ChatDeleteMode.cmdString(chatDeleteMode) // JavaScript
|
||||
'/_delete ' + ChatRef.cmdString(chatRef) + ' ' + ChatDeleteMode.cmdString(chatDeleteMode) + (notice ? ' notice=' + JSON.stringify(notice) : '') // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/_delete ' + ChatRef_cmd_string(chatRef) + ' ' + ChatDeleteMode_cmd_string(chatDeleteMode) # Python
|
||||
'/_delete ' + ChatRef_cmd_string(chatRef) + ' ' + ChatDeleteMode_cmd_string(chatDeleteMode) + ((' notice=' + json.dumps(notice)) if notice is not None else '') # Python
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
@@ -129,6 +129,7 @@ This file is generated automatically.
|
||||
- [InvitationLinkPlan](#invitationlinkplan)
|
||||
- [InvitedBy](#invitedby)
|
||||
- [LinkContent](#linkcontent)
|
||||
- [LinkNotice](#linknotice)
|
||||
- [LinkOwnerSig](#linkownersig)
|
||||
- [LinkPreview](#linkpreview)
|
||||
- [LocalBadge](#localbadge)
|
||||
@@ -1240,6 +1241,11 @@ UnsupportedConnReq:
|
||||
ConnReqMessageProhibited:
|
||||
- type: "connReqMessageProhibited"
|
||||
|
||||
LinkNotice:
|
||||
- type: "linkNotice"
|
||||
- expiresAt: UTCTime?
|
||||
- reason: [ReportReason](#reportreason)?
|
||||
|
||||
ContactNotReady:
|
||||
- type: "contactNotReady"
|
||||
- contact: [Contact](#contact)
|
||||
@@ -1939,6 +1945,11 @@ ContactViaAddress:
|
||||
- type: "contactViaAddress"
|
||||
- contact: [Contact](#contact)
|
||||
|
||||
LinkNotice:
|
||||
- type: "linkNotice"
|
||||
- expiresAt: UTCTime?
|
||||
- reason: [ReportReason](#reportreason)?
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -2557,6 +2568,11 @@ UpdateRequired:
|
||||
- type: "updateRequired"
|
||||
- groupSLinkData_: [GroupShortLinkData](#groupshortlinkdata)?
|
||||
|
||||
LinkNotice:
|
||||
- type: "linkNotice"
|
||||
- expiresAt: UTCTime?
|
||||
- reason: [ReportReason](#reportreason)?
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -2849,6 +2865,15 @@ Unknown:
|
||||
- json: JSONObject
|
||||
|
||||
|
||||
---
|
||||
|
||||
## LinkNotice
|
||||
|
||||
**Record type**:
|
||||
- ttl: int64?
|
||||
- reason: [ReportReason](#reportreason)?
|
||||
|
||||
|
||||
---
|
||||
|
||||
## LinkOwnerSig
|
||||
|
||||
@@ -115,7 +115,7 @@ chatCommandsDocsData =
|
||||
("APIAcceptMember", [], "Accept group member. Requires Admin role.", ["CRMemberAccepted", "CRChatCmdError"], [TD "CEGroupMemberNotActive" "Member is not connected yet"], Just UNBackground, "/_accept member #" <> Param "groupId" <> " " <> Param "groupMemberId" <> " " <> Param "memberRole"),
|
||||
("APIMembersRole", [], "Set members role. Requires Admin role.", ["CRMembersRoleUser", "CRChatCmdError"], [], Just UNBackground, "/_member role #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> " " <> Param "memberRole"),
|
||||
("APIBlockMembersForAll", [], "Block members. Requires Moderator role.", ["CRMembersBlockedForAllUser", "CRChatCmdError"], [], Just UNBackground, "/_block #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "blocked" "blocked" Nothing),
|
||||
("APIRemoveMembers", [], "Remove members. Requires Admin role.", ["CRUserDeletedMembers", "CRChatCmdError"], ["CEGroupMemberNotFound"], Just UNBackground, "/_remove #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "messages" "withMessages" (Just False)),
|
||||
("APIRemoveMembers", [], "Remove members. Requires Admin role.", ["CRUserDeletedMembers", "CRChatCmdError"], ["CEGroupMemberNotFound"], Just UNBackground, "/_remove #" <> Param "groupId" <> " " <> Join ',' "groupMemberIds" <> OnOffParam "messages" "withMessages" (Just False) <> Optional "" (" notice=" <> Json "$0") "notice"),
|
||||
("APILeaveGroup", [], "Leave group.", ["CRLeftMemberUser", "CRChatCmdError"], [], Just UNBackground, "/_leave #" <> Param "groupId"),
|
||||
("APIListMembers", [], "Get group members.", ["CRGroupMembers", "CRChatCmdError"], [], Nothing, "/_members #" <> Param "groupId"),
|
||||
("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"),
|
||||
@@ -151,7 +151,7 @@ chatCommandsDocsData =
|
||||
[ ("APIListContacts", [], "Get contacts.", ["CRContactsList", "CRChatCmdError"], [], Nothing, "/_contacts " <> Param "userId"),
|
||||
("APIListGroups", [], "Get groups.", ["CRGroupsList", "CRChatCmdError"], [], Nothing, "/_groups " <> Param "userId" <> Optional "" (" @" <> Param "$0") "contactId_" <> Optional "" (" " <> Param "$0") "search"),
|
||||
("APIGetChats", [], "Get chat previews. Supports time-based pagination — use this instead of APIListContacts / APIListGroups when scanning at scale (those load every record into memory and fail on large databases).", ["CRApiChats", "CRChatCmdError"], [], Nothing, "/_get chats " <> Param "userId" <> OnOffParam "pcc" "pendingConnections" (Just False) <> Optional "" (" " <> Param "$0") "pagination" <> " " <> Json "query"),
|
||||
("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode"),
|
||||
("APIDeleteChat", [], "Delete chat.", ["CRContactDeleted", "CRContactConnectionDeleted", "CRGroupDeletedUser", "CRChatCmdError"], [], Just UNBackground, "/_delete " <> Param "chatRef" <> " " <> Param "chatDeleteMode" <> Optional "" (" notice=" <> Json "$0") "notice"),
|
||||
("APISetGroupCustomData", [], "Set group custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom #" <> Param "groupId" <> Optional "" (" " <> Json "$0") "customData"),
|
||||
("APISetContactCustomData", [], "Set contact custom data.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set custom @" <> Param "contactId" <> Optional "" (" " <> Json "$0") "customData"),
|
||||
("APISetUserAutoAcceptMemberContacts", [], "Set auto-accept member contacts.", ["CRCmdOk", "CRChatCmdError"], [], Nothing, "/_set accept member contacts " <> Param "userId" <> " " <> OnOff "onOff"),
|
||||
|
||||
@@ -319,6 +319,7 @@ chatTypesDocsData =
|
||||
(sti @InvitationLinkPlan, STUnion, "ILP", [], "", ""),
|
||||
(sti @InvitedBy, STUnion, "IB", [], "", ""),
|
||||
(sti @LinkContent, STUnion, "LC", [], "", ""),
|
||||
(sti @LinkNotice, STRecord, "", [], "", ""),
|
||||
(sti @LinkOwnerSig, STRecord, "", [], "", ""),
|
||||
(sti @LinkPreview, STRecord, "", [], "", ""),
|
||||
(sti @BadgeInfo, STRecord, "", [], "", ""),
|
||||
@@ -559,6 +560,7 @@ deriving instance Generic JSONCIDirection
|
||||
deriving instance Generic JSONCIFileStatus
|
||||
deriving instance Generic JSONCIStatus
|
||||
deriving instance Generic LinkContent
|
||||
deriving instance Generic LinkNotice
|
||||
deriving instance Generic LinkOwnerSig
|
||||
deriving instance Generic LinkPreview
|
||||
deriving instance Generic BadgeInfo
|
||||
|
||||
@@ -254,11 +254,11 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o
|
||||
|
||||
`x.grp.mem.con` message is sent by members connecting inside group to inviting member, to notify the inviting member they have completed the connection and no longer require forwarding messages between them.
|
||||
|
||||
`x.grp.mem.del` message is sent to delete a member - it is sent to all members by the member who deletes the member referenced in this message. This message MUST only be sent by members with `admin` or `owner` role. Receiving clients MUST ignore this message if it is received from member with `member` role.
|
||||
`x.grp.mem.del` message is sent to delete a member - it is sent to all members by the member who deletes the member referenced in this message. This message MUST only be sent by members with `admin` or `owner` role. Receiving clients MUST ignore this message if it is received from member with `member` role. The client of the deleted member applies optional `notice` property to the group link (see [Link notices](#link-notices)).
|
||||
|
||||
`x.grp.leave` message is sent to all members by the member leaving the group. If the only group `owner` leaves the group, it will not be possible to delete it with `x.grp.del` message - but all members can still leave the group with `x.grp.leave` message and then delete a local copy of the group.
|
||||
|
||||
`x.grp.del` message is sent to all members by the member who deletes the group. Clients who received this message SHOULD keep a local copy of the deleted group, until it is deleted by the user. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role.
|
||||
`x.grp.del` message is sent to all members by the member who deletes the group. Clients who received this message SHOULD keep a local copy of the deleted group, until it is deleted by the user. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role. Receiving clients apply optional `notice` property to the business address of a deleted business chat (see [Link notices](#link-notices)).
|
||||
|
||||
`x.grp.info` message is sent to all members by the member who updated group profile. Only group owners can update group profiles. Clients MAY implement some conflict resolution strategy - it is currently not implemented by SimpleX Chat client. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role.
|
||||
|
||||
@@ -266,6 +266,12 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o
|
||||
|
||||
`x.grp.msg.forward` message is sent by inviting member to forward messages between introduced members, while they are connecting.
|
||||
|
||||
### Link notices
|
||||
|
||||
`x.grp.mem.del`, `x.grp.del` and `x.direct.del` messages MAY include `notice` property with optional `ttl` (seconds) and optional `reason` (`spam`, `content`, `community`, `profile` or `other`). The receiving client stores the notice for the link it joined or connected through, as recorded in its own group or contact record, and does not connect via this link until the notice expires. The notice expires `ttl` seconds after the server timestamp of the message; a notice without `ttl` does not expire. A new notice for the same link replaces the previous one. Clients MUST ignore a malformed notice and process the message.
|
||||
|
||||
When `x.direct.del` message has `"silent": true` property, the receiving client stores the notice and does not delete the contact. Clients send it when the contact is deleted without notification, only to contacts with chat protocol version 21 or later.
|
||||
|
||||
### Channels: relay-mediated groups
|
||||
|
||||
Channels are groups where message delivery is mediated by dedicated relay members rather than by direct connections between all members. Channels extend the group sub-protocol with additional roles (`relay`, `observer`), message signing for administrative actions, a binary batch format for signed and forwarded messages, and an asynchronous delivery pipeline.
|
||||
|
||||
@@ -319,13 +319,14 @@ export interface APIRemoveMembers {
|
||||
groupId: number // int64
|
||||
groupMemberIds: number[] // int64, non-empty
|
||||
withMessages: boolean
|
||||
notice?: T.LinkNotice
|
||||
}
|
||||
|
||||
export namespace APIRemoveMembers {
|
||||
export type Response = CR.UserDeletedMembers | CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: APIRemoveMembers): string {
|
||||
return '/_remove #' + self.groupId + ' ' + self.groupMemberIds.join(',') + (self.withMessages ? ' messages=on' : '')
|
||||
return '/_remove #' + self.groupId + ' ' + self.groupMemberIds.join(',') + (self.withMessages ? ' messages=on' : '') + (self.notice ? ' notice=' + JSON.stringify(self.notice) : '')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,13 +682,14 @@ export namespace APIGetChats {
|
||||
export interface APIDeleteChat {
|
||||
chatRef: T.ChatRef
|
||||
chatDeleteMode: T.ChatDeleteMode
|
||||
notice?: T.LinkNotice
|
||||
}
|
||||
|
||||
export namespace APIDeleteChat {
|
||||
export type Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: APIDeleteChat): string {
|
||||
return '/_delete ' + T.ChatRef.cmdString(self.chatRef) + ' ' + T.ChatDeleteMode.cmdString(self.chatDeleteMode)
|
||||
return '/_delete ' + T.ChatRef.cmdString(self.chatRef) + ' ' + T.ChatDeleteMode.cmdString(self.chatDeleteMode) + (self.notice ? ' notice=' + JSON.stringify(self.notice) : '')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1183,6 +1183,7 @@ export type ChatErrorType =
|
||||
| ChatErrorType.NotResolvedLocally
|
||||
| ChatErrorType.UnsupportedConnReq
|
||||
| ChatErrorType.ConnReqMessageProhibited
|
||||
| ChatErrorType.LinkNotice
|
||||
| ChatErrorType.ContactNotReady
|
||||
| ChatErrorType.ContactNotActive
|
||||
| ChatErrorType.ContactDisabled
|
||||
@@ -1263,6 +1264,7 @@ export namespace ChatErrorType {
|
||||
| "notResolvedLocally"
|
||||
| "unsupportedConnReq"
|
||||
| "connReqMessageProhibited"
|
||||
| "linkNotice"
|
||||
| "contactNotReady"
|
||||
| "contactNotActive"
|
||||
| "contactDisabled"
|
||||
@@ -1434,6 +1436,12 @@ export namespace ChatErrorType {
|
||||
type: "connReqMessageProhibited"
|
||||
}
|
||||
|
||||
export interface LinkNotice extends Interface {
|
||||
type: "linkNotice"
|
||||
expiresAt?: string // ISO-8601 timestamp
|
||||
reason?: ReportReason
|
||||
}
|
||||
|
||||
export interface ContactNotReady extends Interface {
|
||||
type: "contactNotReady"
|
||||
contact: Contact
|
||||
@@ -2183,6 +2191,7 @@ export type ContactAddressPlan =
|
||||
| ContactAddressPlan.ConnectingProhibit
|
||||
| ContactAddressPlan.Known
|
||||
| ContactAddressPlan.ContactViaAddress
|
||||
| ContactAddressPlan.LinkNotice
|
||||
|
||||
export namespace ContactAddressPlan {
|
||||
export type Tag =
|
||||
@@ -2192,6 +2201,7 @@ export namespace ContactAddressPlan {
|
||||
| "connectingProhibit"
|
||||
| "known"
|
||||
| "contactViaAddress"
|
||||
| "linkNotice"
|
||||
|
||||
interface Interface {
|
||||
type: Tag
|
||||
@@ -2225,6 +2235,12 @@ export namespace ContactAddressPlan {
|
||||
type: "contactViaAddress"
|
||||
contact: Contact
|
||||
}
|
||||
|
||||
export interface LinkNotice extends Interface {
|
||||
type: "linkNotice"
|
||||
expiresAt?: string // ISO-8601 timestamp
|
||||
reason?: ReportReason
|
||||
}
|
||||
}
|
||||
|
||||
export interface ContactShortLinkData {
|
||||
@@ -2821,6 +2837,7 @@ export type GroupLinkPlan =
|
||||
| GroupLinkPlan.Known
|
||||
| GroupLinkPlan.NoRelays
|
||||
| GroupLinkPlan.UpdateRequired
|
||||
| GroupLinkPlan.LinkNotice
|
||||
|
||||
export namespace GroupLinkPlan {
|
||||
export type Tag =
|
||||
@@ -2831,6 +2848,7 @@ export namespace GroupLinkPlan {
|
||||
| "known"
|
||||
| "noRelays"
|
||||
| "updateRequired"
|
||||
| "linkNotice"
|
||||
|
||||
interface Interface {
|
||||
type: Tag
|
||||
@@ -2874,6 +2892,12 @@ export namespace GroupLinkPlan {
|
||||
type: "updateRequired"
|
||||
groupSLinkData_?: GroupShortLinkData
|
||||
}
|
||||
|
||||
export interface LinkNotice extends Interface {
|
||||
type: "linkNotice"
|
||||
expiresAt?: string // ISO-8601 timestamp
|
||||
reason?: ReportReason
|
||||
}
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
@@ -3119,6 +3143,11 @@ export namespace LinkContent {
|
||||
}
|
||||
}
|
||||
|
||||
export interface LinkNotice {
|
||||
ttl?: number // int64
|
||||
reason?: ReportReason
|
||||
}
|
||||
|
||||
export interface LinkOwnerSig {
|
||||
ownerId?: string
|
||||
chatBinding: string
|
||||
|
||||
@@ -176,8 +176,8 @@ export class ChatClient {
|
||||
}
|
||||
}
|
||||
|
||||
async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}): Promise<void> {
|
||||
const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode}))
|
||||
async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}, notice?: T.LinkNotice): Promise<void> {
|
||||
const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode, notice}))
|
||||
switch (chatType) {
|
||||
case T.ChatType.Direct:
|
||||
if (r.type === "contactDeleted") return
|
||||
@@ -267,8 +267,8 @@ export class ChatClient {
|
||||
throw new ChatCommandError("error joining group", r)
|
||||
}
|
||||
|
||||
async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false): Promise<T.GroupMember[]> {
|
||||
const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages}))
|
||||
async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false, notice?: T.LinkNotice): Promise<T.GroupMember[]> {
|
||||
const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages, notice}))
|
||||
if (r.type === "userDeletedMembers") return r.members
|
||||
throw new ChatCommandError("error removing member", r)
|
||||
}
|
||||
|
||||
@@ -597,8 +597,8 @@ export class ChatApi {
|
||||
* Remove members. Requires Admin role.
|
||||
* Network usage: background.
|
||||
*/
|
||||
async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false): Promise<T.GroupMember[]> {
|
||||
const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages}))
|
||||
async apiRemoveMembers(groupId: number, memberIds: number[], withMessages = false, notice?: T.LinkNotice): Promise<T.GroupMember[]> {
|
||||
const r = await this.sendChatCmd(CC.APIRemoveMembers.cmdString({groupId, groupMemberIds: memberIds, withMessages, notice}))
|
||||
if (r.type === "userDeletedMembers") return r.members
|
||||
throw new ChatCommandError("error removing member", r)
|
||||
}
|
||||
@@ -806,8 +806,8 @@ export class ChatApi {
|
||||
* Delete chat.
|
||||
* Network usage: background.
|
||||
*/
|
||||
async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}): Promise<void> {
|
||||
const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode}))
|
||||
async apiDeleteChat(chatType: T.ChatType, chatId: number, deleteMode: T.ChatDeleteMode = {type: "full", notify: true}, notice?: T.LinkNotice): Promise<void> {
|
||||
const r = await this.sendChatCmd(CC.APIDeleteChat.cmdString({chatRef: {chatType, chatId}, chatDeleteMode: deleteMode, notice}))
|
||||
switch (chatType) {
|
||||
case T.ChatType.Direct:
|
||||
if (r.type === "contactDeleted") return
|
||||
|
||||
@@ -282,10 +282,11 @@ class APIRemoveMembers(TypedDict):
|
||||
groupId: int # int64
|
||||
groupMemberIds: list[int] # int64, non-empty
|
||||
withMessages: bool
|
||||
notice: NotRequired["T.LinkNotice"]
|
||||
|
||||
|
||||
def APIRemoveMembers_cmd_string(self: APIRemoveMembers) -> str:
|
||||
return '/_remove #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + (' messages=on' if self['withMessages'] else '')
|
||||
return '/_remove #' + str(self['groupId']) + ' ' + ','.join(map(str, self['groupMemberIds'])) + (' messages=on' if self['withMessages'] else '') + ((' notice=' + json.dumps(self.get('notice'))) if self.get('notice') is not None else '')
|
||||
|
||||
APIRemoveMembers_Response = CR.UserDeletedMembers | CR.ChatCmdError
|
||||
|
||||
@@ -599,10 +600,11 @@ APIGetChats_Response = CR.ApiChats | CR.ChatCmdError
|
||||
class APIDeleteChat(TypedDict):
|
||||
chatRef: "T.ChatRef"
|
||||
chatDeleteMode: "T.ChatDeleteMode"
|
||||
notice: NotRequired["T.LinkNotice"]
|
||||
|
||||
|
||||
def APIDeleteChat_cmd_string(self: APIDeleteChat) -> str:
|
||||
return '/_delete ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + T.ChatDeleteMode_cmd_string(self['chatDeleteMode'])
|
||||
return '/_delete ' + T.ChatRef_cmd_string(self['chatRef']) + ' ' + T.ChatDeleteMode_cmd_string(self['chatDeleteMode']) + ((' notice=' + json.dumps(self.get('notice'))) if self.get('notice') is not None else '')
|
||||
|
||||
APIDeleteChat_Response = CR.ContactDeleted | CR.ContactConnectionDeleted | CR.GroupDeletedUser | CR.ChatCmdError
|
||||
|
||||
|
||||
@@ -892,6 +892,11 @@ class ChatErrorType_unsupportedConnReq(TypedDict):
|
||||
class ChatErrorType_connReqMessageProhibited(TypedDict):
|
||||
type: Literal["connReqMessageProhibited"]
|
||||
|
||||
class ChatErrorType_linkNotice(TypedDict):
|
||||
type: Literal["linkNotice"]
|
||||
expiresAt: NotRequired[str] # ISO-8601 timestamp
|
||||
reason: NotRequired["ReportReason"]
|
||||
|
||||
class ChatErrorType_contactNotReady(TypedDict):
|
||||
type: Literal["contactNotReady"]
|
||||
contact: "Contact"
|
||||
@@ -1124,6 +1129,7 @@ ChatErrorType = (
|
||||
| ChatErrorType_notResolvedLocally
|
||||
| ChatErrorType_unsupportedConnReq
|
||||
| ChatErrorType_connReqMessageProhibited
|
||||
| ChatErrorType_linkNotice
|
||||
| ChatErrorType_contactNotReady
|
||||
| ChatErrorType_contactNotActive
|
||||
| ChatErrorType_contactDisabled
|
||||
@@ -1179,7 +1185,7 @@ ChatErrorType = (
|
||||
| ChatErrorType_exception
|
||||
)
|
||||
|
||||
ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"]
|
||||
ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "linkNotice", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"]
|
||||
|
||||
ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"]
|
||||
|
||||
@@ -1547,6 +1553,11 @@ class ContactAddressPlan_contactViaAddress(TypedDict):
|
||||
type: Literal["contactViaAddress"]
|
||||
contact: "Contact"
|
||||
|
||||
class ContactAddressPlan_linkNotice(TypedDict):
|
||||
type: Literal["linkNotice"]
|
||||
expiresAt: NotRequired[str] # ISO-8601 timestamp
|
||||
reason: NotRequired["ReportReason"]
|
||||
|
||||
ContactAddressPlan = (
|
||||
ContactAddressPlan_ok
|
||||
| ContactAddressPlan_ownLink
|
||||
@@ -1554,9 +1565,10 @@ ContactAddressPlan = (
|
||||
| ContactAddressPlan_connectingProhibit
|
||||
| ContactAddressPlan_known
|
||||
| ContactAddressPlan_contactViaAddress
|
||||
| ContactAddressPlan_linkNotice
|
||||
)
|
||||
|
||||
ContactAddressPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "contactViaAddress"]
|
||||
ContactAddressPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "contactViaAddress", "linkNotice"]
|
||||
|
||||
class ContactShortLinkData(TypedDict):
|
||||
profile: "Profile"
|
||||
@@ -2001,6 +2013,11 @@ class GroupLinkPlan_updateRequired(TypedDict):
|
||||
type: Literal["updateRequired"]
|
||||
groupSLinkData_: NotRequired["GroupShortLinkData"]
|
||||
|
||||
class GroupLinkPlan_linkNotice(TypedDict):
|
||||
type: Literal["linkNotice"]
|
||||
expiresAt: NotRequired[str] # ISO-8601 timestamp
|
||||
reason: NotRequired["ReportReason"]
|
||||
|
||||
GroupLinkPlan = (
|
||||
GroupLinkPlan_ok
|
||||
| GroupLinkPlan_ownLink
|
||||
@@ -2009,9 +2026,10 @@ GroupLinkPlan = (
|
||||
| GroupLinkPlan_known
|
||||
| GroupLinkPlan_noRelays
|
||||
| GroupLinkPlan_updateRequired
|
||||
| GroupLinkPlan_linkNotice
|
||||
)
|
||||
|
||||
GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays", "updateRequired"]
|
||||
GroupLinkPlan_Tag = Literal["ok", "ownLink", "connectingConfirmReconnect", "connectingProhibit", "known", "noRelays", "updateRequired", "linkNotice"]
|
||||
|
||||
class GroupMember(TypedDict):
|
||||
groupMemberId: int # int64
|
||||
@@ -2175,6 +2193,10 @@ LinkContent = LinkContent_page | LinkContent_image | LinkContent_video | LinkCon
|
||||
|
||||
LinkContent_Tag = Literal["page", "image", "video", "unknown"]
|
||||
|
||||
class LinkNotice(TypedDict):
|
||||
ttl: NotRequired[int] # int64
|
||||
reason: NotRequired["ReportReason"]
|
||||
|
||||
class LinkOwnerSig(TypedDict):
|
||||
ownerId: NotRequired[str]
|
||||
chatBinding: str
|
||||
|
||||
@@ -168,6 +168,7 @@ library
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260926_link_notices
|
||||
else
|
||||
exposed-modules:
|
||||
Simplex.Chat.Archive
|
||||
@@ -345,6 +346,7 @@ library
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260926_link_notices
|
||||
other-modules:
|
||||
Paths_simplex_chat
|
||||
hs-source-dirs:
|
||||
|
||||
@@ -430,7 +430,7 @@ data ChatCommand
|
||||
| APIChatRead {chatRef :: ChatRef}
|
||||
| APIChatItemsRead {chatRef :: ChatRef, chatItemIds :: NonEmpty ChatItemId}
|
||||
| APIChatUnread {chatRef :: ChatRef, unreadChat :: Bool}
|
||||
| APIDeleteChat {chatRef :: ChatRef, chatDeleteMode :: ChatDeleteMode} -- currently delete mode settings are only applied to direct chats
|
||||
| APIDeleteChat {chatRef :: ChatRef, chatDeleteMode :: ChatDeleteMode, notice :: Maybe LinkNotice} -- currently delete mode settings are only applied to direct chats
|
||||
| APIClearChat {chatRef :: ChatRef}
|
||||
| APIAcceptContact {incognito :: IncognitoEnabled, contactReqId :: Int64}
|
||||
| APIRejectContact {contactReqId :: Int64, notify :: Bool}
|
||||
@@ -468,7 +468,7 @@ data ChatCommand
|
||||
| APIDeleteMemberSupportChat GroupId GroupMemberId
|
||||
| APIMembersRole {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, memberRole :: GroupMemberRole}
|
||||
| APIBlockMembersForAll {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, blocked :: Bool}
|
||||
| APIRemoveMembers {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, withMessages :: Bool}
|
||||
| APIRemoveMembers {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, withMessages :: Bool, notice :: Maybe LinkNotice}
|
||||
| APILeaveGroup {groupId :: GroupId}
|
||||
| APIListMembers {groupId :: GroupId}
|
||||
| APIUpdateGroupProfile {groupId :: GroupId, groupProfile :: GroupProfile}
|
||||
@@ -617,7 +617,7 @@ data ChatCommand
|
||||
| AcceptMember GroupName ContactName GroupMemberRole
|
||||
| MemberRole GroupName ContactName GroupMemberRole
|
||||
| BlockForAll GroupName ContactName Bool
|
||||
| RemoveMembers {groupName :: GroupName, members :: NonEmpty ContactName, withMessages :: Bool}
|
||||
| RemoveMembers {groupName :: GroupName, members :: NonEmpty ContactName, withMessages :: Bool, notice :: Maybe LinkNotice}
|
||||
| LeaveGroup GroupName
|
||||
| AllowRelayGroup GroupName
|
||||
| DeleteGroup GroupName
|
||||
@@ -1170,6 +1170,7 @@ data ContactAddressPlan
|
||||
| CAPConnectingProhibit {contact :: Contact}
|
||||
| CAPKnown {contact :: Contact}
|
||||
| CAPContactViaAddress {contact :: Contact}
|
||||
| CAPLinkNotice {expiresAt :: Maybe UTCTime, reason :: Maybe ReportReason}
|
||||
deriving (Show)
|
||||
|
||||
data GroupLinkPlan
|
||||
@@ -1180,6 +1181,7 @@ data GroupLinkPlan
|
||||
| GLPKnown {groupInfo :: GroupInfo, groupUpdated :: Bool, ownerVerification :: Maybe OwnerVerification, linkOwners :: ListDef GroupLinkOwner}
|
||||
| GLPNoRelays {groupSLinkData_ :: Maybe GroupShortLinkData}
|
||||
| GLPUpdateRequired {groupSLinkData_ :: Maybe GroupShortLinkData}
|
||||
| GLPLinkNotice {expiresAt :: Maybe UTCTime, reason :: Maybe ReportReason}
|
||||
deriving (Show)
|
||||
|
||||
data GroupLinkOwner = GroupLinkOwner
|
||||
@@ -1515,6 +1517,7 @@ data ChatErrorType
|
||||
| CEUnsupportedConnReq
|
||||
| CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String}
|
||||
| CEConnReqMessageProhibited
|
||||
| CELinkNotice {expiresAt :: Maybe UTCTime, reason :: Maybe ReportReason}
|
||||
| CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)}
|
||||
| CEContactNotReady {contact :: Contact}
|
||||
| CEContactNotActive {contact :: Contact}
|
||||
|
||||
@@ -1338,7 +1338,7 @@ processChatCommand cxt nm = \case
|
||||
liftIO $ updateNoteFolderUnreadChat db user nf unreadChat
|
||||
ok user
|
||||
_ -> throwCmdError "not supported"
|
||||
APIDeleteChat cRef@(ChatRef cType chatId scope) cdm -> withUser $ \user@User {userId} -> case cType of
|
||||
APIDeleteChat cRef@(ChatRef cType chatId scope) cdm notice_ -> withUser $ \user@User {userId} -> case cType of
|
||||
CTDirect -> do
|
||||
ct <- withFastStore $ \db -> getContact db cxt user chatId
|
||||
filesInfo <- withFastStore' $ \db -> getContactFileInfo db user ct
|
||||
@@ -1363,16 +1363,22 @@ processChatCommand cxt nm = \case
|
||||
getContact db cxt user chatId
|
||||
pure $ CRContactDeleted user ct'
|
||||
CDMMessages -> do
|
||||
when (isJust notice_) $ throwCmdError "notice requires deleting the contact"
|
||||
void $ processChatCommand cxt nm $ APIClearChat cRef
|
||||
withFastStore' $ \db -> setContactChatDeleted db user ct True
|
||||
pure $ CRContactDeleted user ct {chatDeleted = True}
|
||||
where
|
||||
sendDelDeleteConns ct notify = do
|
||||
let doSendDel = contactReady ct && contactActive ct && notify
|
||||
when doSendDel $ void (sendDirectContactMessage user ct XDirectDel) `catchAllErrors` const (pure ())
|
||||
let delMsg_
|
||||
| not (contactReady ct && contactActive ct) = Nothing
|
||||
| notify = Just $ XDirectDel False notice_
|
||||
| isJust notice_ && any (\Connection {peerChatVRange} -> maxVersion peerChatVRange >= linkNoticeVersion) (contactConn ct) = Just $ XDirectDel True notice_
|
||||
| otherwise = Nothing
|
||||
forM_ delMsg_ $ \msg -> void (sendDirectContactMessage user ct msg) `catchAllErrors` const (pure ())
|
||||
contactConnIds <- map aConnId <$> withFastStore' (\db -> getContactConnections db cxt userId ct)
|
||||
deleteAgentConnectionsAsync' contactConnIds doSendDel
|
||||
deleteAgentConnectionsAsync' contactConnIds (isJust delMsg_)
|
||||
CTContactConnection -> withConnectionLock "deleteChat contactConnection" chatId $ do
|
||||
when (isJust notice_) $ throwCmdError "notice is not supported for pending connections"
|
||||
conn@PendingContactConnection {pccAgentConnId = AgentConnId acId} <- withFastStore $ \db -> getPendingContactConnection db userId chatId
|
||||
deleteAgentConnectionAsync acId
|
||||
withFastStore' $ \db -> deletePendingContactConnection db userId chatId
|
||||
@@ -1391,7 +1397,7 @@ processChatCommand cxt nm = \case
|
||||
let doSendDel = memberActive membership && isOwner
|
||||
msgSigned <-
|
||||
if doSendDel
|
||||
then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user g recipients XGrpDel
|
||||
then (\SndMessage {signedMsg_} -> isJust signedMsg_) <$> sendGroupMessage' user g recipients (XGrpDel notice_)
|
||||
else pure False
|
||||
deleteGroupLinkIfExists user gInfo
|
||||
deleteMembersConnections' user members doSendDel
|
||||
@@ -2269,6 +2275,7 @@ processChatCommand cxt nm = \case
|
||||
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDSnd (DirectChat ct') ci]
|
||||
pure $ CRStartedConnectionToContact user ct' customUserProfile
|
||||
Just PreparedContact {connLinkToConnect = ACCL SCMContact ccLink, welcomeSharedMsgId, requestSharedMsgId} -> do
|
||||
checkLinkNotice $ connFullLink ccLink
|
||||
msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of
|
||||
Just smId -> pure (smId, mc)
|
||||
Nothing -> do
|
||||
@@ -2297,6 +2304,7 @@ processChatCommand cxt nm = \case
|
||||
case gInfo of
|
||||
GroupInfo {preparedGroup = Nothing} -> throwCmdError "group doesn't have link to connect"
|
||||
GroupInfo {useRelays = BoolDef True, preparedGroup = Just PreparedGroup {connLinkToConnect}} -> do
|
||||
checkLinkNotice $ connFullLink connLinkToConnect
|
||||
sLnk <- case connShortLink' connLinkToConnect of
|
||||
Just sl -> pure sl
|
||||
Nothing -> throwChatError $ CEException "failed to retrieve relays: no short link"
|
||||
@@ -2359,6 +2367,7 @@ processChatCommand cxt nm = \case
|
||||
newConnIds <- getAgentConnShortLinkAsync user CFGetRelayDataJoin Nothing relayLink
|
||||
withStore' $ \db -> createRelayMemberConnectionAsync db user gInfo' relayMember relayLink newConnIds subMode
|
||||
GroupInfo {preparedGroup = Just PreparedGroup {connLinkToConnect, welcomeSharedMsgId, requestSharedMsgId}} -> do
|
||||
checkLinkNotice $ connFullLink connLinkToConnect
|
||||
hostMember <- withFastStore $ \db -> getHostMember db cxt user groupId
|
||||
msg_ <- forM msgContent_ $ \mc -> case requestSharedMsgId of
|
||||
Just smId -> pure (smId, mc)
|
||||
@@ -2388,10 +2397,11 @@ processChatCommand cxt nm = \case
|
||||
(conn, incognitoProfile) <- connectViaInvitation user incognito ccLink Nothing
|
||||
let pcc = mkPendingContactConnection conn $ Just ccLink
|
||||
pure $ CRSentConfirmation user pcc incognitoProfile
|
||||
ACCL SCMContact ccLink@(CCLink _ sLnk) -> do
|
||||
ACCL SCMContact ccLink@(CCLink cReq sLnk) -> do
|
||||
case sLnk of
|
||||
Just (CSLContact _ CCTChannel _ _) -> throwChatError $ CECommandError "channel links must be connected via APIConnectPreparedGroup"
|
||||
_ -> pure ()
|
||||
checkLinkNotice cReq
|
||||
connectViaContact user Nothing incognito ccLink Nothing Nothing >>= \case
|
||||
CVRConnectedContact ct -> pure $ CRContactAlreadyExists user ct
|
||||
CVRSentInvitation conn incognitoProfile -> pure $ CRSentInvitation user (mkPendingContactConnection conn Nothing) incognitoProfile
|
||||
@@ -2443,7 +2453,7 @@ processChatCommand cxt nm = \case
|
||||
ConnectSimplex incognito -> withUser $ \user -> do
|
||||
plan <- contactRequestPlan user adminContactReq Nothing Nothing `catchAllErrors` const (pure $ CPContactAddress (CAPOk Nothing Nothing))
|
||||
connectWithPlan user incognito (ACCL SCMContact (CCLink adminContactReq Nothing)) Nothing Nothing plan
|
||||
DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm
|
||||
DeleteContact cName cdm -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId Nothing) cdm Nothing
|
||||
ClearContact cName -> withContactName cName $ \chatId -> APIClearChat $ ChatRef CTDirect chatId Nothing
|
||||
APIListContacts userId -> withUserId userId $ \user ->
|
||||
CRContactsList user <$> withFastStore' (\db -> getUserContacts db cxt user)
|
||||
@@ -3071,7 +3081,7 @@ processChatCommand cxt nm = \case
|
||||
let content = CISndGroupEvent $ SGEMemberBlocked groupMemberId (fromLocalProfile memberProfile) blockFlag
|
||||
ts = ciContentTexts content
|
||||
in NewSndChatItemData msg content ts M.empty Nothing Nothing Nothing
|
||||
APIRemoveMembers {groupId, groupMemberIds, withMessages} -> withUser $ \user ->
|
||||
APIRemoveMembers {groupId, groupMemberIds, withMessages, notice} -> withUser $ \user ->
|
||||
withGroupLock "removeMembers" groupId $ do
|
||||
-- TODO [relays] possible optimization is to read only required members + relays
|
||||
(Group gInfo members, gks) <- withFastStore $ \db -> getGroupKeys_ db cxt user groupId
|
||||
@@ -3145,7 +3155,7 @@ processChatCommand cxt nm = \case
|
||||
Nothing -> pure ([], [], [], False)
|
||||
Just memsToDelete' -> do
|
||||
let chatScope = toChatScope <$> chatScopeInfo
|
||||
events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer) memsToDelete'
|
||||
events = L.map (\GroupMember {memberId} -> XGrpMemDel memberId withMessages rosterVer notice) memsToDelete'
|
||||
(msgs_, _gsr) <- sendGroupMessages user g chatScope False recipients False events
|
||||
let signed = any (either (const False) (\SndMessage {signedMsg_} -> isJust signedMsg_)) msgs_
|
||||
itemsData_ = zipWith (fmap . sndItemData) memsToDelete (L.toList msgs_)
|
||||
@@ -3245,12 +3255,12 @@ processChatCommand cxt nm = \case
|
||||
AcceptMember gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIAcceptMember gId gMemberId memRole
|
||||
MemberRole gName gMemberName memRole -> withMemberName gName gMemberName $ \gId gMemberId -> APIMembersRole gId [gMemberId] memRole
|
||||
BlockForAll gName gMemberName blocked -> withMemberName gName gMemberName $ \gId gMemberId -> APIBlockMembersForAll gId [gMemberId] blocked
|
||||
RemoveMembers gName gMemberNames withMessages -> withUser $ \user -> do
|
||||
RemoveMembers gName gMemberNames withMessages notice -> withUser $ \user -> do
|
||||
(gId, gMemberIds) <- withStore $ \db -> do
|
||||
gId <- getGroupIdByName db user gName
|
||||
gMemberIds <- mapM (getGroupMemberIdByName db user gId) gMemberNames
|
||||
pure (gId, gMemberIds)
|
||||
processChatCommand cxt nm $ APIRemoveMembers gId gMemberIds withMessages
|
||||
processChatCommand cxt nm $ APIRemoveMembers gId gMemberIds withMessages notice
|
||||
LeaveGroup gName -> withUser $ \user -> do
|
||||
groupId <- withFastStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand cxt nm $ APILeaveGroup groupId
|
||||
@@ -3259,7 +3269,7 @@ processChatCommand cxt nm = \case
|
||||
processChatCommand cxt nm $ APIAllowRelayGroup groupId
|
||||
DeleteGroup gName -> withUser $ \user -> do
|
||||
groupId <- withFastStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand cxt nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True)
|
||||
processChatCommand cxt nm $ APIDeleteChat (ChatRef CTGroup groupId Nothing) (CDMFull True) Nothing
|
||||
ClearGroup gName -> withUser $ \user -> do
|
||||
groupId <- withFastStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand cxt nm $ APIClearChat (ChatRef CTGroup groupId Nothing)
|
||||
@@ -3898,7 +3908,8 @@ processChatCommand cxt nm = \case
|
||||
conn' <- joinContact user conn cReq incognitoProfile xContactId welcomeSharedMsgId msg_ gInfo_ relayMemberId_ pqSup
|
||||
pure $ CVRSentInvitation conn' incognitoProfile
|
||||
connectContactViaAddress :: User -> IncognitoEnabled -> Contact -> CreatedLinkContact -> CM ChatResponse
|
||||
connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) =
|
||||
connectContactViaAddress user@User {userId} incognito ct@Contact {contactId, activeConn} (CCLink cReq shortLink) = do
|
||||
checkLinkNotice cReq
|
||||
withInvitationLock "connectContactViaAddress" (strEncode cReq) $
|
||||
case activeConn of
|
||||
Nothing -> do
|
||||
@@ -4638,11 +4649,13 @@ processChatCommand cxt nm = \case
|
||||
contactRequestPlan :: User -> ConnReqContact -> Maybe ContactShortLinkData -> Maybe OwnerVerification -> CM ConnectionPlan
|
||||
contactRequestPlan user cReq cld ov = do
|
||||
let cReqSchemas = contactCReqSchemas cReq
|
||||
cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas
|
||||
cReqHashes = contactCReqHashes cReq
|
||||
plan p = pure $ CPContactAddress p
|
||||
withFastStore' (\db -> getUserContactLinkByConnReq db user cReqSchemas) >>= \case
|
||||
Just _ -> plan $ CAPOwnLink
|
||||
Nothing ->
|
||||
ts <- liftIO getCurrentTime
|
||||
withFastStore' (\db -> (,) <$> getUserContactLinkByConnReq db user cReqSchemas <*> getLinkNotice db ts cReqHashes) >>= \case
|
||||
(Just _, _) -> plan $ CAPOwnLink
|
||||
(Nothing, Just (expiresAt, reason)) -> plan $ CAPLinkNotice expiresAt reason
|
||||
(Nothing, Nothing) ->
|
||||
withFastStore' (\db -> getContactConnEntityByConnReqHash db cxt user cReqHashes) >>= \case
|
||||
Nothing ->
|
||||
withFastStore' (\db -> getContactWithoutConnViaAddress db cxt user cReqSchemas) >>= \case
|
||||
@@ -4661,11 +4674,13 @@ processChatCommand cxt nm = \case
|
||||
groupJoinRequestPlan :: User -> ConnReqContact -> Maybe GroupShortLinkInfo -> Maybe GroupShortLinkData -> Maybe OwnerVerification -> [GroupLinkOwner] -> CM ConnectionPlan
|
||||
groupJoinRequestPlan user cReq linkInfo gld ov glOwners = do
|
||||
let cReqSchemas = contactCReqSchemas cReq
|
||||
cReqHashes = bimap contactCReqHash contactCReqHash cReqSchemas
|
||||
cReqHashes = contactCReqHashes cReq
|
||||
plan p = pure $ CPGroupLink p
|
||||
withFastStore' (\db -> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas) >>= \case
|
||||
Just g -> plan $ GLPOwnLink g
|
||||
Nothing -> do
|
||||
ts <- liftIO getCurrentTime
|
||||
withFastStore' (\db -> (,) <$> getGroupInfoByUserContactLinkConnReq db cxt user cReqSchemas <*> getLinkNotice db ts cReqHashes) >>= \case
|
||||
(Just g, _) -> plan $ GLPOwnLink g
|
||||
(Nothing, Just (expiresAt, reason)) -> plan $ GLPLinkNotice expiresAt reason
|
||||
(Nothing, Nothing) -> do
|
||||
connEnt_ <- withFastStore' $ \db -> getContactConnEntityByConnReqHash db cxt user cReqHashes
|
||||
gInfo_ <- withFastStore' $ \db -> getGroupInfoByGroupLinkHash db cxt user cReqHashes
|
||||
case (gInfo_, connEnt_) of
|
||||
@@ -4691,6 +4706,13 @@ processChatCommand cxt nm = \case
|
||||
( CRContactUri crData {crScheme = SSSimplex} e2e,
|
||||
CRContactUri crData {crScheme = simplexChat} e2e
|
||||
)
|
||||
contactCReqHashes :: ConnReqContact -> (ConnReqUriHash, ConnReqUriHash)
|
||||
contactCReqHashes = bimap contactCReqHash contactCReqHash . contactCReqSchemas
|
||||
checkLinkNotice :: ConnReqContact -> CM ()
|
||||
checkLinkNotice cReq = do
|
||||
ts <- liftIO getCurrentTime
|
||||
withFastStore' (\db -> getLinkNotice db ts $ contactCReqHashes cReq)
|
||||
>>= mapM_ (\(expiresAt, reason) -> throwChatError $ CELinkNotice expiresAt reason)
|
||||
-- This function is needed, as UI uses simplex:/ schema in message view, so that the links can be handled without browser,
|
||||
-- and short links are stored with server hostname schema, so they wouldn't match without it.
|
||||
serverShortLink :: ConnShortLink m -> ConnShortLink m
|
||||
@@ -5818,6 +5840,7 @@ cleanupManager = do
|
||||
cleanupDeliveryJobs `catchAllErrors` eToView
|
||||
-- TODO possibly, also cleanup async commands
|
||||
cleanupProbes `catchAllErrors` eToView
|
||||
cleanupLinkNotices `catchAllErrors` eToView
|
||||
liftIO $ threadDelay' $ diffToMicroseconds interval
|
||||
where
|
||||
runWithoutInitialDelay cleanupInterval = flip catchAllErrors eToView $ do
|
||||
@@ -5887,6 +5910,9 @@ cleanupManager = do
|
||||
ts <- liftIO getCurrentTime
|
||||
let cutoffTs = addUTCTime (-(14 * nominalDay)) ts
|
||||
withStore' (`deleteOldProbes` cutoffTs)
|
||||
cleanupLinkNotices = do
|
||||
ts <- liftIO getCurrentTime
|
||||
withStore' (`deleteExpiredLinkNotices` ts)
|
||||
|
||||
deleteInProgressGroup :: User -> GroupInfo -> CM ()
|
||||
deleteInProgressGroup user gInfo = do
|
||||
@@ -6103,7 +6129,7 @@ chatCommandP =
|
||||
"/_read chat " *> (APIChatRead <$> chatRefP),
|
||||
"/_read chat items " *> (APIChatItemsRead <$> chatRefP <*> _strP),
|
||||
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
|
||||
"/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode),
|
||||
"/_delete " *> (APIDeleteChat <$> chatRefP <*> chatDeleteMode <*> optional (" notice=" *> jsonP)),
|
||||
"/_clear chat " *> (APIClearChat <$> chatRefP),
|
||||
"/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal),
|
||||
"/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)),
|
||||
@@ -6145,7 +6171,7 @@ chatCommandP =
|
||||
"/_delete member chat #" *> (APIDeleteMemberSupportChat <$> A.decimal <* A.space <*> A.decimal),
|
||||
"/_member role #" *> (APIMembersRole <$> A.decimal <*> _strP <*> memberRole),
|
||||
"/_block #" *> (APIBlockMembersForAll <$> A.decimal <*> _strP <* " blocked=" <*> onOffP),
|
||||
"/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)),
|
||||
"/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False) <*> optional (" notice=" *> jsonP)),
|
||||
"/_leave #" *> (APILeaveGroup <$> A.decimal),
|
||||
"/_members #" *> (APIListMembers <$> A.decimal),
|
||||
-- "/_archive conversations #" *> (APIArchiveGroupConversations <$> A.decimal <*> _strP),
|
||||
@@ -6245,7 +6271,7 @@ chatCommandP =
|
||||
("/member role " <|> "/mr ") *> char_ '#' *> (MemberRole <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> memberRole),
|
||||
"/block for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure True),
|
||||
"/unblock for all #" *> (BlockForAll <$> displayNameP <* A.space <*> (char_ '@' *> displayNameP) <*> pure False),
|
||||
("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (L.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',') <*> (" messages=" *> onOffP <|> pure False)),
|
||||
("/remove " <|> "/rm ") *> char_ '#' *> (RemoveMembers <$> displayNameP <* A.space <*> (L.fromList <$> (char_ '@' *> displayNameP) `A.sepBy1'` A.char ',') <*> (" messages=" *> onOffP <|> pure False) <*> optional (" notice=" *> jsonP)),
|
||||
("/leave " <|> "/l ") *> char_ '#' *> (LeaveGroup <$> displayNameP),
|
||||
("/delete #" <|> "/d #") *> (DeleteGroup <$> displayNameP),
|
||||
("/delete " <|> "/d ") *> char_ '@' *> (DeleteContact <$> displayNameP <*> chatDeleteMode),
|
||||
|
||||
@@ -584,7 +584,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
XFileCancel sharedMsgId -> xFileCancel ct'' sharedMsgId
|
||||
XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct'' sharedMsgId fileConnReq_ fName
|
||||
XInfo p _ -> xInfo ct'' p
|
||||
XDirectDel -> xDirectDel ct'' msg msgMeta
|
||||
XDirectDel silent notice_ -> xDirectDel ct'' silent notice_ msg msgMeta
|
||||
XGrpInv gInv -> processGroupInvitation ct'' gInv msg msgMeta
|
||||
XInfoProbe probe -> xInfoProbe (COMContact ct'') probe
|
||||
XInfoProbeCheck probeHash -> xInfoProbeCheck (COMContact ct'') probeHash
|
||||
@@ -1093,11 +1093,11 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
XGrpMemRole memId memRole memberKey rosterVer -> fmap ctx <$> xGrpMemRole (GIK gInfo' gks) Nothing m'' memId memRole memberKey rosterVer msg brokerTs
|
||||
XGrpMemRestrict memId memRestrictions -> fmap ctx <$> xGrpMemRestrict gInfo' m'' memId memRestrictions msg brokerTs
|
||||
XGrpMemCon memId -> Nothing <$ xGrpMemCon gInfo' m'' memId
|
||||
XGrpMemDel memId withMessages rosterVer -> case encoding @e of
|
||||
SJson -> fmap ctx <$> xGrpMemDel (GIK gInfo' gks) Nothing m'' memId withMessages rosterVer verifiedMsg msg brokerTs False
|
||||
XGrpMemDel memId withMessages rosterVer notice_ -> case encoding @e of
|
||||
SJson -> fmap ctx <$> xGrpMemDel (GIK gInfo' gks) Nothing m'' memId withMessages rosterVer notice_ verifiedMsg msg brokerTs False
|
||||
SBinary -> pure Nothing
|
||||
XGrpLeave -> fmap ctx <$> xGrpLeave (GIK gInfo' gks) m'' msg brokerTs
|
||||
XGrpDel -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' msg brokerTs
|
||||
XGrpDel notice_ -> Just (DeliveryTaskContext (DJSGroup {jobSpec = DJRelayRemoved}) False) <$ xGrpDel gInfo' m'' notice_ msg brokerTs
|
||||
XGrpInfo p' -> fmap ctx <$> xGrpInfo (GIK gInfo' gks) m'' p' msg brokerTs
|
||||
XGrpPrefs ps' -> fmap ctx <$> xGrpPrefs gInfo' m'' ps' msg
|
||||
XGrpRoster gr -> fmap ctx <$> xGrpRoster gInfo' m'' m'' gr verifiedMsg sharedMsgId_ brokerTs
|
||||
@@ -2731,24 +2731,26 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
xInfo :: Contact -> Profile -> CM ()
|
||||
xInfo c p' = void $ processContactProfileUpdate c p' True
|
||||
|
||||
xDirectDel :: Contact -> RcvMessage -> MsgMeta -> CM ()
|
||||
xDirectDel c msg msgMeta =
|
||||
if directOrUsed c
|
||||
then do
|
||||
(ct', contactConns) <- withStore' $ \db -> do
|
||||
ct' <- updateContactStatus db user c CSDeleted
|
||||
(ct',) <$> getContactConnections db cxt userId ct'
|
||||
deleteAgentConnectionsAsync $ map aConnId contactConns
|
||||
forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
|
||||
activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted}
|
||||
let ct'' = ct' {activeConn = activeConn'} :: Contact
|
||||
(ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted)
|
||||
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
|
||||
toView $ CEvtContactDeletedByContact user ct''
|
||||
else do
|
||||
contactConns <- withStore' $ \db -> getContactConnections db cxt userId c
|
||||
deleteAgentConnectionsAsync $ map aConnId contactConns
|
||||
withStore $ \db -> deleteContact db user c
|
||||
xDirectDel :: Contact -> Bool -> Maybe LinkNotice -> RcvMessage -> MsgMeta -> CM ()
|
||||
xDirectDel c silent notice_ msg msgMeta = do
|
||||
forM_ notice_ $ \notice -> withStore' $ \db -> setContactLinkNotice db c brokerTs notice
|
||||
unless silent $
|
||||
if directOrUsed c
|
||||
then do
|
||||
(ct', contactConns) <- withStore' $ \db -> do
|
||||
ct' <- updateContactStatus db user c CSDeleted
|
||||
(ct',) <$> getContactConnections db cxt userId ct'
|
||||
deleteAgentConnectionsAsync $ map aConnId contactConns
|
||||
forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
|
||||
activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted}
|
||||
let ct'' = ct' {activeConn = activeConn'} :: Contact
|
||||
(ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted)
|
||||
toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci]
|
||||
toView $ CEvtContactDeletedByContact user ct''
|
||||
else do
|
||||
contactConns <- withStore' $ \db -> getContactConnections db cxt userId c
|
||||
deleteAgentConnectionsAsync $ map aConnId contactConns
|
||||
withStore $ \db -> deleteContact db user c
|
||||
where
|
||||
brokerTs = metaBrokerTs msgMeta
|
||||
|
||||
@@ -3678,8 +3680,8 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
withStore $ \db -> setMemberVectorRelationConnected db sendingMem refMem MRSubjectConnected
|
||||
withStore $ \db -> setMemberVectorRelationConnected db refMem sendingMem MRReferencedConnected
|
||||
|
||||
xGrpMemDel :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemDel g@(GIK gInfo@GroupInfo {membership} gks) fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do
|
||||
xGrpMemDel :: GroupInfoKeys -> Maybe GroupMember -> GroupMember -> MemberId -> Bool -> Maybe VersionRoster -> Maybe LinkNotice -> VerifiedMsg 'Json -> RcvMessage -> UTCTime -> Bool -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemDel g@(GIK gInfo@GroupInfo {membership} gks) fwdRelay_ m@GroupMember {memberRole = senderRole} memId withMessages rosterVer_ notice_ verifiedMsg msg@RcvMessage {msgSigned} brokerTs forwarded = do
|
||||
let GroupMember {memberId = membershipMemId} = membership
|
||||
if membershipMemId == memId
|
||||
then applyAtRosterVersion g fwdRelay_ m rosterVer_ $ checkRole membership $ do
|
||||
@@ -3689,6 +3691,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
withStore' $ \db -> do
|
||||
updateGroupMemberStatus db userId membership GSMemRemoved
|
||||
when (maybe False (/= RSRejected) (relayOwnStatus gInfo)) $ updateRelayOwnStatus_ db gInfo RSInactive
|
||||
forM_ notice_ $ setGroupLinkNotice db gInfo brokerTs
|
||||
let membership' = membership {memberStatus = GSMemRemoved}
|
||||
when withMessages $ deleteMessages gInfo membership'
|
||||
deleteMemberItem msg gInfo RGEUserDeleted
|
||||
@@ -3775,11 +3778,13 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
toView $ CEvtLeftMember user gInfo''' m' {memberStatus = GSMemLeft} msgSigned
|
||||
pure $ memberEventDeliveryScope m
|
||||
|
||||
xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM ()
|
||||
xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} msg@RcvMessage {msgSigned} brokerTs = do
|
||||
xGrpDel :: GroupInfo -> GroupMember -> Maybe LinkNotice -> RcvMessage -> UTCTime -> CM ()
|
||||
xGrpDel gInfo@GroupInfo {membership} m@GroupMember {memberRole} notice_ msg@RcvMessage {msgSigned} brokerTs = do
|
||||
when (memberRole /= GROwner) $ throwChatError $ CEGroupUserRole gInfo GROwner
|
||||
deleteGroupLinkIfExists user gInfo
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemGroupDeleted
|
||||
withStore' $ \db -> do
|
||||
updateGroupMemberStatus db userId membership GSMemGroupDeleted
|
||||
forM_ notice_ $ setGroupLinkNotice db gInfo brokerTs
|
||||
-- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay
|
||||
unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False
|
||||
(gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m
|
||||
@@ -3974,9 +3979,9 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
|
||||
XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew g author memInfo msgScope rcvMsg msgTs
|
||||
XGrpMemRole memId memRole memberKey rosterVer -> withAuthor XGrpMemRole_ $ \author -> void $ xGrpMemRole g (Just m) author memId memRole memberKey rosterVer rcvMsg msgTs
|
||||
XGrpMemRestrict memId memRestrictions -> withAuthor XGrpMemRestrict_ $ \author -> void $ xGrpMemRestrict gInfo author memId memRestrictions rcvMsg msgTs
|
||||
XGrpMemDel memId withMessages rosterVer -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel g (Just m) author memId withMessages rosterVer verifiedMsg rcvMsg msgTs True
|
||||
XGrpMemDel memId withMessages rosterVer notice_ -> withAuthor XGrpMemDel_ $ \author -> void $ xGrpMemDel g (Just m) author memId withMessages rosterVer notice_ verifiedMsg rcvMsg msgTs True
|
||||
XGrpLeave -> withAuthor XGrpLeave_ $ \author -> void $ xGrpLeave g author rcvMsg msgTs
|
||||
XGrpDel -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author rcvMsg msgTs
|
||||
XGrpDel notice_ -> withAuthor XGrpDel_ $ \author -> void $ xGrpDel gInfo author notice_ rcvMsg msgTs
|
||||
XGrpInfo p' -> withAuthor XGrpInfo_ $ \author -> void $ xGrpInfo g author p' rcvMsg msgTs
|
||||
XGrpPrefs ps' -> withAuthor XGrpPrefs_ $ \author -> void $ xGrpPrefs gInfo author ps' rcvMsg
|
||||
XGrpRoster gr -> withAuthor XGrpRoster_ $ \author -> void $ xGrpRoster gInfo m author gr verifiedMsg sharedMsgId_ msgTs
|
||||
|
||||
@@ -92,7 +92,7 @@ import Simplex.Messaging.Version hiding (version)
|
||||
-- This indirection is needed for backward/forward compatibility testing.
|
||||
-- Testing with real app versions is still needed, as tests use the current code with different version ranges, not the old code.
|
||||
currentChatVersion :: VersionChat
|
||||
currentChatVersion = VersionChat 20
|
||||
currentChatVersion = VersionChat 21
|
||||
|
||||
-- This should not be used directly in code, instead use `chatVRange` from ChatConfig (see comment above)
|
||||
supportedChatVRange :: VersionRangeChat
|
||||
@@ -140,6 +140,9 @@ groupRosterVersion = VersionChat 19
|
||||
groupMemberKeyVersion :: VersionChat
|
||||
groupMemberKeyVersion = VersionChat 20
|
||||
|
||||
linkNoticeVersion :: VersionChat
|
||||
linkNoticeVersion = VersionChat 21
|
||||
|
||||
data ConnectionEntity
|
||||
= RcvDirectMsgConnection {entityConnection :: Connection, contact :: Maybe Contact}
|
||||
| RcvGroupMsgConnection {entityConnection :: Connection, groupInfo :: GroupInfo, groupMember :: GroupMember}
|
||||
@@ -288,6 +291,7 @@ instance StrEncoding ReportReason where
|
||||
A.takeTill (== ' ') >>= \case
|
||||
"spam" -> pure RRSpam
|
||||
"content" -> pure RRContent
|
||||
"illegal" -> pure RRContent
|
||||
"community" -> pure RRCommunity
|
||||
"profile" -> pure RRProfile
|
||||
"other" -> pure RROther
|
||||
@@ -300,6 +304,18 @@ instance ToJSON ReportReason where
|
||||
toJSON = strToJSON
|
||||
toEncoding = strToJEncoding
|
||||
|
||||
instance ToField ReportReason where toField = toField . safeDecodeUtf8 . strEncode
|
||||
|
||||
instance FromField ReportReason where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8
|
||||
|
||||
data LinkNotice = LinkNotice
|
||||
{ ttl :: Maybe Int64,
|
||||
reason :: Maybe ReportReason
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''LinkNotice)
|
||||
|
||||
data ChatMessage e = ChatMessage
|
||||
{ chatVRange :: VersionRangeChat,
|
||||
msgId :: Maybe SharedMsgId,
|
||||
@@ -463,7 +479,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
|
||||
XInfo :: {profile :: Profile, memberKey :: Maybe MemberKey} -> ChatMsgEvent 'Json
|
||||
XContact :: {profile :: Profile, memberKey :: Maybe MemberKey, contactReqId :: Maybe XContactId, welcomeMsgId :: Maybe SharedMsgId, requestMsg :: Maybe (SharedMsgId, MsgContent)} -> ChatMsgEvent 'Json
|
||||
XMember :: {profile :: Profile, newMemberId :: MemberId, newMemberKey :: MemberKey, viaRelay :: Maybe MemberId} -> ChatMsgEvent 'Json
|
||||
XDirectDel :: ChatMsgEvent 'Json
|
||||
XDirectDel :: Bool -> Maybe LinkNotice -> ChatMsgEvent 'Json
|
||||
XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json
|
||||
XGrpAcpt :: MemberId -> Maybe MemberKey -> ChatMsgEvent 'Json
|
||||
XGrpLinkInv :: GroupLinkInvitation -> ChatMsgEvent 'Json
|
||||
@@ -485,9 +501,9 @@ data ChatMsgEvent (e :: MsgEncoding) where
|
||||
XGrpMemRestrict :: MemberId -> MemberRestrictions -> ChatMsgEvent 'Json
|
||||
XGrpMemCon :: MemberId -> ChatMsgEvent 'Json
|
||||
XGrpMemConAll :: MemberId -> ChatMsgEvent 'Json -- TODO not implemented
|
||||
XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> ChatMsgEvent 'Json
|
||||
XGrpMemDel :: MemberId -> Bool -> Maybe VersionRoster -> Maybe LinkNotice -> ChatMsgEvent 'Json
|
||||
XGrpLeave :: ChatMsgEvent 'Json
|
||||
XGrpDel :: ChatMsgEvent 'Json
|
||||
XGrpDel :: Maybe LinkNotice -> ChatMsgEvent 'Json
|
||||
XGrpInfo :: GroupProfile -> ChatMsgEvent 'Json
|
||||
XGrpPrefs :: GroupPreferences -> ChatMsgEvent 'Json
|
||||
XGrpDirectInv :: ConnReqInvitation -> Maybe MsgContent -> Maybe MsgScope -> ChatMsgEvent 'Json
|
||||
@@ -534,7 +550,7 @@ isForwardedGroupMsg ev = case ev of
|
||||
XGrpMemRestrict {} -> True
|
||||
XGrpMemDel {} -> True
|
||||
XGrpLeave -> True
|
||||
XGrpDel -> True
|
||||
XGrpDel _ -> True
|
||||
XGrpInfo _ -> True
|
||||
XGrpPrefs _ -> True
|
||||
XGrpRoster _ -> True
|
||||
@@ -1256,7 +1272,7 @@ toCMEventTag msg = case msg of
|
||||
XInfo {} -> XInfo_
|
||||
XContact {} -> XContact_
|
||||
XMember {} -> XMember_
|
||||
XDirectDel -> XDirectDel_
|
||||
XDirectDel {} -> XDirectDel_
|
||||
XGrpInv _ -> XGrpInv_
|
||||
XGrpAcpt {} -> XGrpAcpt_
|
||||
XGrpLinkInv _ -> XGrpLinkInv_
|
||||
@@ -1280,7 +1296,7 @@ toCMEventTag msg = case msg of
|
||||
XGrpMemConAll _ -> XGrpMemConAll_
|
||||
XGrpMemDel {} -> XGrpMemDel_
|
||||
XGrpLeave -> XGrpLeave_
|
||||
XGrpDel -> XGrpDel_
|
||||
XGrpDel _ -> XGrpDel_
|
||||
XGrpInfo _ -> XGrpInfo_
|
||||
XGrpPrefs _ -> XGrpPrefs_
|
||||
XGrpDirectInv {} -> XGrpDirectInv_
|
||||
@@ -1425,7 +1441,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
let requestMsg = (,) <$> reqMsgId <*> reqContent
|
||||
pure XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg}
|
||||
XMember_ -> XMember <$> p "profile" <*> p "newMemberId" <*> p "newMemberKey" <*> opt "viaRelay"
|
||||
XDirectDel_ -> pure XDirectDel
|
||||
XDirectDel_ -> XDirectDel <$> Right (fromRight False $ p "silent") <*> Right (fromRight Nothing $ opt "notice")
|
||||
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
|
||||
XGrpAcpt_ -> XGrpAcpt <$> p "memberId" <*> opt "memberKey"
|
||||
XGrpLinkInv_ -> XGrpLinkInv <$> p "groupLinkInvitation"
|
||||
@@ -1450,9 +1466,9 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
XGrpMemRestrict_ -> XGrpMemRestrict <$> p "memberId" <*> p "memberRestrictions"
|
||||
XGrpMemCon_ -> XGrpMemCon <$> p "memberId"
|
||||
XGrpMemConAll_ -> XGrpMemConAll <$> p "memberId"
|
||||
XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion"
|
||||
XGrpMemDel_ -> XGrpMemDel <$> p "memberId" <*> Right (fromRight False $ p "messages") <*> opt "rosterVersion" <*> Right (fromRight Nothing $ opt "notice")
|
||||
XGrpLeave_ -> pure XGrpLeave
|
||||
XGrpDel_ -> pure XGrpDel
|
||||
XGrpDel_ -> XGrpDel <$> Right (fromRight Nothing $ opt "notice")
|
||||
XGrpInfo_ -> XGrpInfo <$> p "groupProfile"
|
||||
XGrpPrefs_ -> XGrpPrefs <$> p "groupPreferences"
|
||||
XGrpDirectInv_ -> XGrpDirectInv <$> p "connReq" <*> opt "content" <*> opt "scope"
|
||||
@@ -1501,7 +1517,7 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XInfo {profile, memberKey} -> o $ ("memberKey" .=? memberKey) ["profile" .= profile]
|
||||
XContact {profile, memberKey, contactReqId, welcomeMsgId, requestMsg} -> o $ ("contactReqId" .=? contactReqId) $ ("welcomeMsgId" .=? welcomeMsgId) $ ("msgId" .=? (fst <$> requestMsg)) $ ("content" .=? (snd <$> requestMsg)) $ ("memberKey" .=? memberKey) $ ["profile" .= profile]
|
||||
XMember {profile, newMemberId, newMemberKey, viaRelay} -> o $ ("viaRelay" .=? viaRelay) ["profile" .= profile, "newMemberId" .= newMemberId, "newMemberKey" .= newMemberKey]
|
||||
XDirectDel -> JM.empty
|
||||
XDirectDel silent notice -> o $ ("silent" .=? justTrue silent) $ ("notice" .=? notice) []
|
||||
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
|
||||
XGrpAcpt memId memberKey -> o $ ("memberKey" .=? memberKey) ["memberId" .= memId]
|
||||
XGrpLinkInv groupLinkInv -> o ["groupLinkInvitation" .= groupLinkInv]
|
||||
@@ -1525,9 +1541,9 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XGrpMemRestrict memId memRestrictions -> o ["memberId" .= memId, "memberRestrictions" .= memRestrictions]
|
||||
XGrpMemCon memId -> o ["memberId" .= memId]
|
||||
XGrpMemConAll memId -> o ["memberId" .= memId]
|
||||
XGrpMemDel memId messages rosterVersion -> o $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
|
||||
XGrpMemDel memId messages rosterVersion notice -> o $ ("notice" .=? notice) $ ("rosterVersion" .=? rosterVersion) $ ("messages" .=? if messages then Just True else Nothing) ["memberId" .= memId]
|
||||
XGrpLeave -> JM.empty
|
||||
XGrpDel -> JM.empty
|
||||
XGrpDel notice -> o $ ("notice" .=? notice) []
|
||||
XGrpInfo p -> o ["groupProfile" .= p]
|
||||
XGrpPrefs p -> o ["groupPreferences" .= p]
|
||||
XGrpDirectInv connReq content scope -> o $ ("content" .=? content) $ ("scope" .=? scope) ["connReq" .= connReq]
|
||||
|
||||
@@ -53,6 +53,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260904_file_badges
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260915_user_badges
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260926_link_notices
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Text, Maybe Text)]
|
||||
@@ -105,7 +106,8 @@ schemaMigrations =
|
||||
("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges),
|
||||
("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges),
|
||||
("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors),
|
||||
("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json)
|
||||
("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json),
|
||||
("20260926_link_notices", m20260926_link_notices, Just down_m20260926_link_notices)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -1229,6 +1229,28 @@ ALTER TABLE test_chat_schema.known_servers ALTER COLUMN server_id ADD GENERATED
|
||||
|
||||
|
||||
|
||||
CREATE TABLE test_chat_schema.link_notices (
|
||||
link_notice_id bigint NOT NULL,
|
||||
link_hash bytea NOT NULL,
|
||||
expires_at timestamp with time zone,
|
||||
reason text,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL
|
||||
);
|
||||
|
||||
|
||||
|
||||
ALTER TABLE test_chat_schema.link_notices ALTER COLUMN link_notice_id ADD GENERATED ALWAYS AS IDENTITY (
|
||||
SEQUENCE NAME test_chat_schema.link_notices_link_notice_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1
|
||||
);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE test_chat_schema.messages (
|
||||
message_id bigint NOT NULL,
|
||||
msg_sent smallint NOT NULL,
|
||||
@@ -2003,6 +2025,11 @@ ALTER TABLE ONLY test_chat_schema.known_servers
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY test_chat_schema.link_notices
|
||||
ADD CONSTRAINT link_notices_pkey PRIMARY KEY (link_notice_id);
|
||||
|
||||
|
||||
|
||||
ALTER TABLE ONLY test_chat_schema.messages
|
||||
ADD CONSTRAINT messages_pkey PRIMARY KEY (message_id);
|
||||
|
||||
@@ -2760,6 +2787,10 @@ CREATE INDEX idx_groups_via_group_link_uri_hash ON test_chat_schema.groups USING
|
||||
|
||||
|
||||
|
||||
CREATE UNIQUE INDEX idx_link_notices_link_hash ON test_chat_schema.link_notices USING btree (link_hash);
|
||||
|
||||
|
||||
|
||||
CREATE INDEX idx_messages_author_group_member_id ON test_chat_schema.messages USING btree (author_group_member_id);
|
||||
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260904_file_badges
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260915_user_badges
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260926_link_notices
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -351,7 +352,8 @@ schemaMigrations =
|
||||
("20260904_file_badges", m20260904_file_badges, Just down_m20260904_file_badges),
|
||||
("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges),
|
||||
("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors),
|
||||
("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json)
|
||||
("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json),
|
||||
("20260926_link_notices", m20260926_link_notices, Just down_m20260926_link_notices)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -3847,6 +3847,17 @@ Query:
|
||||
Plan:
|
||||
SEARCH badge_ledger USING INDEX idx_badge_ledger_purchase (badge_purchase_id=?)
|
||||
|
||||
Query:
|
||||
SELECT expires_at, reason
|
||||
FROM link_notices
|
||||
WHERE link_hash IN (?,?) AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY expires_at IS NULL DESC, expires_at DESC
|
||||
LIMIT 1
|
||||
|
||||
Plan:
|
||||
SEARCH link_notices USING INDEX idx_link_notices_link_hash (link_hash=?)
|
||||
USE TEMP B-TREE FOR ORDER BY
|
||||
|
||||
Query:
|
||||
SELECT f.file_id
|
||||
FROM files f
|
||||
@@ -5064,6 +5075,16 @@ Query:
|
||||
|
||||
Plan:
|
||||
|
||||
Query:
|
||||
INSERT INTO link_notices (link_hash, expires_at, reason, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT (link_hash) DO UPDATE SET
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
reason = EXCLUDED.reason,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
|
||||
Plan:
|
||||
|
||||
Query:
|
||||
INSERT INTO msg_deliveries
|
||||
(message_id, connection_id, agent_msg_id, chat_ts, created_at, updated_at, delivery_status)
|
||||
@@ -7498,6 +7519,10 @@ Query: SELECT count(1) FROM group_members
|
||||
Plan:
|
||||
SCAN group_members USING COVERING INDEX idx_group_members_invited_by_group_member_id
|
||||
|
||||
Query: SELECT count(1) FROM link_notices
|
||||
Plan:
|
||||
SCAN link_notices USING COVERING INDEX idx_link_notices_link_hash
|
||||
|
||||
Query: SELECT count(1) FROM pending_group_messages
|
||||
Plan:
|
||||
SCAN pending_group_messages USING COVERING INDEX idx_pending_group_messages_group_member_id
|
||||
@@ -7776,6 +7801,14 @@ Query: SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE conne
|
||||
Plan:
|
||||
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: SELECT via_contact_uri_hash FROM connections WHERE connection_id = ? AND via_contact_uri_hash IS NOT NULL
|
||||
Plan:
|
||||
SEARCH connections USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: SELECT via_group_link_uri_hash FROM groups WHERE group_id = ? AND via_group_link_uri_hash IS NOT NULL
|
||||
Plan:
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ?
|
||||
Plan:
|
||||
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
@@ -981,6 +981,14 @@ CREATE TABLE badge_code_redemptions(
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, code)
|
||||
) STRICT;
|
||||
CREATE TABLE link_notices(
|
||||
link_notice_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
link_hash BLOB NOT NULL,
|
||||
expires_at TEXT,
|
||||
reason TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
display_name,
|
||||
full_name
|
||||
@@ -1544,6 +1552,7 @@ CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions(
|
||||
CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases(
|
||||
badge_code_redemption_id
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_link_notices_link_hash ON link_notices(link_hash);
|
||||
CREATE TRIGGER on_group_members_insert_update_summary
|
||||
AFTER INSERT ON group_members
|
||||
FOR EACH ROW
|
||||
|
||||
@@ -30,11 +30,13 @@ import Data.Int (Int64)
|
||||
import Data.Maybe (fromMaybe, isJust, listToMaybe)
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||
import Data.Time.Calendar (fromGregorian)
|
||||
import Data.Time.Clock (UTCTime (..), addUTCTime, getCurrentTime)
|
||||
import Data.Type.Equality
|
||||
import Simplex.Chat.Badges (BadgeRow, badgeToRow, rowToBadge, verifyBadge_)
|
||||
import Simplex.Chat.Names (SimplexDomainProof, SimplexDomainClaim (..), claimDomain)
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Protocol (LinkNotice (..), ReportReason)
|
||||
import Simplex.Chat.Remote.Types
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
@@ -986,6 +988,53 @@ setViaGroupLinkUri db groupId connId = do
|
||||
|]
|
||||
(viaContactUri, viaContactUriHash, groupId)
|
||||
|
||||
setGroupLinkNotice :: DB.Connection -> GroupInfo -> UTCTime -> LinkNotice -> IO ()
|
||||
setGroupLinkNotice db GroupInfo {groupId} brokerTs notice =
|
||||
maybeFirstRow fromOnly (DB.query db "SELECT via_group_link_uri_hash FROM groups WHERE group_id = ? AND via_group_link_uri_hash IS NOT NULL" (Only groupId))
|
||||
>>= mapM_ (\linkHash -> upsertLinkNotice db linkHash brokerTs notice)
|
||||
|
||||
setContactLinkNotice :: DB.Connection -> Contact -> UTCTime -> LinkNotice -> IO ()
|
||||
setContactLinkNotice db ct brokerTs notice =
|
||||
forM_ (contactConn ct) $ \Connection {connId} ->
|
||||
maybeFirstRow fromOnly (DB.query db "SELECT via_contact_uri_hash FROM connections WHERE connection_id = ? AND via_contact_uri_hash IS NOT NULL" (Only connId))
|
||||
>>= mapM_ (\linkHash -> upsertLinkNotice db linkHash brokerTs notice)
|
||||
|
||||
upsertLinkNotice :: DB.Connection -> ConnReqUriHash -> UTCTime -> LinkNotice -> IO ()
|
||||
upsertLinkNotice db linkHash brokerTs LinkNotice {ttl, reason} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
INSERT INTO link_notices (link_hash, expires_at, reason, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?)
|
||||
ON CONFLICT (link_hash) DO UPDATE SET
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
reason = EXCLUDED.reason,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
|]
|
||||
(linkHash, expiresAt, reason, currentTs, currentTs)
|
||||
where
|
||||
expiresAt = mfilter (< maxLinkNoticeExpiry) $ (\t -> addUTCTime (fromIntegral t) brokerTs) <$> ttl
|
||||
maxLinkNoticeExpiry = UTCTime (fromGregorian 10000 1 1) 0
|
||||
|
||||
getLinkNotice :: DB.Connection -> UTCTime -> (ConnReqUriHash, ConnReqUriHash) -> IO (Maybe (Maybe UTCTime, Maybe ReportReason))
|
||||
getLinkNotice db currentTs (linkHash1, linkHash2) =
|
||||
maybeFirstRow id $
|
||||
DB.query
|
||||
db
|
||||
[sql|
|
||||
SELECT expires_at, reason
|
||||
FROM link_notices
|
||||
WHERE link_hash IN (?,?) AND (expires_at IS NULL OR expires_at > ?)
|
||||
ORDER BY expires_at IS NULL DESC, expires_at DESC
|
||||
LIMIT 1
|
||||
|]
|
||||
(linkHash1, linkHash2, currentTs)
|
||||
|
||||
deleteExpiredLinkNotices :: DB.Connection -> UTCTime -> IO ()
|
||||
deleteExpiredLinkNotices db currentTs =
|
||||
DB.execute db "DELETE FROM link_notices WHERE expires_at <= ?" (Only currentTs)
|
||||
|
||||
deleteConnectionRecord :: DB.Connection -> User -> Int64 -> IO ()
|
||||
deleteConnectionRecord db User {userId} cId = do
|
||||
DB.execute db "DELETE FROM connections WHERE user_id = ? AND connection_id = ?" (userId, cId)
|
||||
|
||||
@@ -1880,6 +1880,11 @@ day = T.pack . formatTime defaultTimeLocale "%Y-%m-%d"
|
||||
dayTime :: UTCTime -> Text
|
||||
dayTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M"
|
||||
|
||||
viewLinkNotice :: Maybe UTCTime -> Maybe ReportReason -> Text
|
||||
viewLinkNotice expiresAt reason =
|
||||
maybe "banned permanently" (("banned until " <>) . dayTime) expiresAt
|
||||
<> maybe "" ((", reason: " <>) . safeDecodeUtf8 . strEncode) reason
|
||||
|
||||
viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
|
||||
viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile =
|
||||
["contact ID: " <> sShow contactId]
|
||||
@@ -2266,6 +2271,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case
|
||||
<> contactDomainLine ct
|
||||
<> ["use " <> ttyToContact' ct <> highlight' "<message>" <> " to send messages"]
|
||||
CAPContactViaAddress ct -> [ctAddr ("known contact without connection " <> ttyContact' ct)] <> contactDomainLine ct
|
||||
CAPLinkNotice expiresAt reason -> [ctAddr $ plain $ viewLinkNotice expiresAt reason]
|
||||
where
|
||||
ctAddr = ("contact address: " <>)
|
||||
addrOrBiz = \case
|
||||
@@ -2300,6 +2306,7 @@ viewConnectionPlan ChatConfig {logLevel, testView} _connLink = \case
|
||||
knownGroup prepared = grpOrBizLink g <> ": known " <> prepared <> grpOrBiz g <> " " <> ttyGroup' g
|
||||
GLPNoRelays _ -> [grpLink "channel has no active relays, please try to join later"]
|
||||
GLPUpdateRequired _ -> [grpLink "this group requires a newer version of the app, please upgrade"]
|
||||
GLPLinkNotice expiresAt reason -> [grpLink $ plain $ viewLinkNotice expiresAt reason]
|
||||
where
|
||||
connecting g = [grpOrBizLink g <> ": connecting to " <> grpOrBiz g <> " " <> ttyGroup' g]
|
||||
grpLink = ("group link: " <>)
|
||||
@@ -2808,6 +2815,7 @@ viewChatError isCmd logLevel testView = \case
|
||||
<> maybe "" (\MsgMetaJSON {rcvId} -> ", agent msg rcv id: " <> show rcvId) msgMeta_
|
||||
]
|
||||
CEConnReqMessageProhibited -> ["message is not allowed with this connection link"]
|
||||
CELinkNotice expiresAt reason -> ["connection link: " <> plain (viewLinkNotice expiresAt reason)]
|
||||
CEContactNotFound cName m_ -> viewContactNotFound cName m_
|
||||
CEContactNotReady c -> [ttyContact' c <> ": not ready"]
|
||||
CEContactDisabled ct -> [ttyContact' ct <> ": disabled, to enable: " <> highlight ("/enable " <> viewContactName ct) <> ", to delete: " <> highlight ("/d " <> viewContactName ct)]
|
||||
|
||||
@@ -67,6 +67,7 @@ chatDirectTests = do
|
||||
it "deleting contact deletes profile" testDeleteContactDeletesProfile
|
||||
it "delete contact keeping conversation" testDeleteContactKeepConversation
|
||||
it "delete conversation keeping contact" testDeleteConversationKeepContact
|
||||
it "delete contact connected via invitation with notice" testDeleteContactInvitationNotice
|
||||
it "direct message quoted replies" testDirectMessageQuotedReply
|
||||
it "direct message update" testDirectMessageUpdate
|
||||
it "direct message edit history" testDirectMessageEditHistory
|
||||
@@ -603,6 +604,18 @@ testDeleteContactKeepConversation =
|
||||
bob ##> "@alice hey"
|
||||
bob <## "alice: not ready"
|
||||
|
||||
testDeleteContactInvitationNotice :: HasCallStack => TestParams -> IO ()
|
||||
testDeleteContactInvitationNotice =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
connectUsers alice bob
|
||||
alice ##> "/_delete @2 messages notice={\"ttl\":86400}"
|
||||
alice <## "bad chat command: notice requires deleting the contact"
|
||||
alice ##> "/_delete @2 full notice={\"ttl\":86400}"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
linkNoticesCount bob `shouldReturn` 0
|
||||
|
||||
testDeleteConversationKeepContact :: HasCallStack => TestParams -> IO ()
|
||||
testDeleteConversationKeepContact =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
@@ -141,6 +141,11 @@ chatGroupTests = do
|
||||
it "own group link" testPlanGroupLinkOwn
|
||||
it "group link without contact - connecting" testPlanGroupLinkConnecting
|
||||
it "re-join existing group after leaving" testPlanGroupLinkLeaveRejoin
|
||||
it "removed member with notice can't re-join" testPlanGroupLinkRemovedNotice
|
||||
it "removal notice expiry and replacement" testPlanGroupLinkRemovedNoticeReplaced
|
||||
it "removal notice applies only to the link used to join" testPlanGroupLinkRemovedNoticeOtherLinks
|
||||
it "group deletion without notice leaves notices unchanged" testPlanGroupLinkDeletedWithoutNotice
|
||||
it "rejected pending member with notice can't re-join" testPlanGroupLinkRejectedNotice
|
||||
#if !defined(dbPostgres)
|
||||
-- TODO [postgres] restore from outdated db backup (same as in agent)
|
||||
describe "group message errors" $ do
|
||||
@@ -293,6 +298,7 @@ chatGroupTests = do
|
||||
it "should change member role (signed)" testChannelChangeRoleSigned
|
||||
it "should block member for all (signed)" testChannelBlockMemberSigned
|
||||
it "should remove member (signed)" testChannelRemoveMemberSigned
|
||||
it "should prevent removed member with notice from re-joining" testChannelRemoveMemberNotice
|
||||
it "should verify member security code via membership keys" testChannelMemberSecurityCode
|
||||
it "should delete channel (signed)" testChannelDeleteGroupSigned
|
||||
it "should delete channel and clean up relay connections" testChannelDeleteGroupCleanup
|
||||
@@ -3035,6 +3041,172 @@ testPlanGroupLinkLeaveRejoin =
|
||||
bob <## "group link: known group #team_1"
|
||||
bob <## "use #team_1 <message> to send messages"
|
||||
|
||||
testPlanGroupLinkRemovedNotice :: HasCallStack => TestParams -> IO ()
|
||||
testPlanGroupLinkRemovedNotice =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
gLink <- createTeamLink alice
|
||||
joinTeamViaLink alice bob gLink "bob"
|
||||
|
||||
alice ##> "/rm team bob notice={\"ttl\":86400,\"reason\":\"spam\"}"
|
||||
removedFromTeam alice bob "bob"
|
||||
|
||||
bob ##> ("/_connect plan 1 " <> gLink)
|
||||
bannedLine bob "group link: " ", reason: spam"
|
||||
bob ##> ("/_connect plan 1 " <> linkAnotherSchema gLink)
|
||||
bannedLine bob "group link: " ", reason: spam"
|
||||
bob ##> ("/c " <> gLink)
|
||||
bannedLine bob "group link: " ", reason: spam"
|
||||
bob ##> ("/_connect 1 " <> gLink)
|
||||
bannedLine bob "connection link: " ", reason: spam"
|
||||
|
||||
bob ##> "/d #team"
|
||||
bob <## "#team: you deleted your local copy of the group"
|
||||
bob ##> ("/_connect plan 1 " <> gLink)
|
||||
bannedLine bob "group link: " ", reason: spam"
|
||||
|
||||
bob ##> "/create user robert"
|
||||
showActiveUser bob "robert"
|
||||
bob ##> ("/_connect plan 2 " <> gLink)
|
||||
bannedLine bob "group link: " ", reason: spam"
|
||||
|
||||
testPlanGroupLinkRemovedNoticeReplaced :: HasCallStack => TestParams -> IO ()
|
||||
testPlanGroupLinkRemovedNoticeReplaced =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
gLink <- createTeamLink alice
|
||||
joinTeamViaLink alice bob gLink "bob"
|
||||
|
||||
alice ##> "/rm team bob"
|
||||
removedFromTeam alice bob "bob"
|
||||
bob ##> ("/_connect plan 1 " <> gLink)
|
||||
bob <## "group link: ok to connect directly"
|
||||
_sLinkData <- getTermLine bob
|
||||
linkNoticesCount bob `shouldReturn` 0
|
||||
|
||||
bob ##> "/d #team"
|
||||
bob <## "#team: you deleted your local copy of the group"
|
||||
joinTeamViaLink alice bob gLink "bob_1"
|
||||
|
||||
alice ##> "/rm team bob_1 notice={\"ttl\":0}"
|
||||
removedFromTeam alice bob "bob_1"
|
||||
bob ##> ("/_connect plan 1 " <> gLink)
|
||||
bob <## "group link: ok to connect directly"
|
||||
_sLinkData <- getTermLine bob
|
||||
linkNoticesCount bob `shouldReturn` 1
|
||||
|
||||
bob ##> "/d #team"
|
||||
bob <## "#team: you deleted your local copy of the group"
|
||||
joinTeamViaLink alice bob gLink "bob_2"
|
||||
|
||||
alice ##> "/rm team bob_2 notice={\"reason\":\"profile\"}"
|
||||
removedFromTeam alice bob "bob_2"
|
||||
bob ##> ("/_connect plan 1 " <> gLink)
|
||||
bob <## "group link: banned permanently, reason: profile"
|
||||
linkNoticesCount bob `shouldReturn` 1
|
||||
|
||||
testPlanGroupLinkRemovedNoticeOtherLinks :: HasCallStack => TestParams -> IO ()
|
||||
testPlanGroupLinkRemovedNoticeOtherLinks =
|
||||
testChat3 aliceProfile bobProfile cathProfile $
|
||||
\alice bob cath -> do
|
||||
gLink <- createTeamLink alice
|
||||
joinTeamViaLink alice bob gLink "bob"
|
||||
alice ##> "/g club"
|
||||
alice <## "group #club is created"
|
||||
alice <## "to add members use /a club <name> or /create link #club"
|
||||
alice ##> "/create link #club"
|
||||
clubLink <- getGroupLink alice "club" GRMember True
|
||||
|
||||
alice ##> "/rm team bob notice={\"ttl\":86400}"
|
||||
removedFromTeam alice bob "bob"
|
||||
bob ##> ("/_connect plan 1 " <> gLink)
|
||||
bannedLine bob "group link: " ""
|
||||
bob ##> ("/_connect plan 1 " <> clubLink)
|
||||
bob <## "group link: ok to connect directly"
|
||||
_sLinkData <- getTermLine bob
|
||||
|
||||
createGroup2 "news" alice cath
|
||||
alice ##> "/rm news cath notice={\"ttl\":86400}"
|
||||
concurrentlyN_
|
||||
[ alice <## "#news: you removed cath from the group (signed)",
|
||||
do
|
||||
cath <## "#news: alice removed you from the group (signed)"
|
||||
cath <## "use /d #news to delete the group"
|
||||
]
|
||||
linkNoticesCount cath `shouldReturn` 0
|
||||
|
||||
testPlanGroupLinkDeletedWithoutNotice :: HasCallStack => TestParams -> IO ()
|
||||
testPlanGroupLinkDeletedWithoutNotice =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
gLink <- createTeamLink alice
|
||||
joinTeamViaLink alice bob gLink "bob"
|
||||
alice ##> "/d #team"
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: you deleted the group (signed)",
|
||||
do
|
||||
bob <## "#team: alice deleted the group (signed)"
|
||||
bob <## "use /d #team to delete the local copy of the group"
|
||||
]
|
||||
linkNoticesCount bob `shouldReturn` 0
|
||||
|
||||
testPlanGroupLinkRejectedNotice :: HasCallStack => TestParams -> IO ()
|
||||
testPlanGroupLinkRejectedNotice =
|
||||
testChatCfg2 cfg aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
gLink <- createTeamLink alice
|
||||
bob ##> ("/c " <> gLink)
|
||||
bob <## "connection request sent!"
|
||||
alice <## "bob (Bob): accepting request to join group #team..."
|
||||
concurrentlyN_
|
||||
[ alice <## "#team: bob connected and pending approval, use /_accept member #1 2 <role> to accept member",
|
||||
do
|
||||
bob <## "#team: joining the group..."
|
||||
bob <## "#team: you joined the group, pending approval"
|
||||
]
|
||||
|
||||
alice ##> "/rm team bob notice={\"ttl\":3600,\"reason\":\"community\"}"
|
||||
alice <## "#team: you removed bob from the group (signed)"
|
||||
bob <## "#team: alice removed you from the group (signed)"
|
||||
bob <## "use /d #team to delete the group"
|
||||
|
||||
bob ##> ("/_connect plan 1 " <> gLink)
|
||||
bannedLine bob "group link: " ", reason: community"
|
||||
where
|
||||
cfg = testCfg {chatHooks = defaultChatHooks {acceptMember = Just (\_ _ _ -> pure $ Right (GAPendingApproval, GRObserver))}}
|
||||
|
||||
createTeamLink :: HasCallStack => TestCC -> IO String
|
||||
createTeamLink alice = do
|
||||
threadDelay 100000
|
||||
alice ##> "/g team"
|
||||
alice <## "group #team is created"
|
||||
alice <## "to add members use /a team <name> or /create link #team"
|
||||
alice ##> "/create link #team"
|
||||
getGroupLink alice "team" GRMember True
|
||||
|
||||
joinTeamViaLink :: HasCallStack => TestCC -> TestCC -> String -> String -> IO ()
|
||||
joinTeamViaLink alice bob gLink bobName = do
|
||||
bob ##> ("/c " <> gLink)
|
||||
bob <## "connection request sent!"
|
||||
alice <## (bobName <> " (Bob): accepting request to join group #team...")
|
||||
concurrentlyN_
|
||||
[ alice <## ("#team: " <> bobName <> " joined the group"),
|
||||
bob
|
||||
<### [ "#team: joining the group...",
|
||||
"#team: you joined the group"
|
||||
]
|
||||
]
|
||||
threadDelay 100000
|
||||
|
||||
removedFromTeam :: HasCallStack => TestCC -> TestCC -> String -> IO ()
|
||||
removedFromTeam alice bob bobName =
|
||||
concurrentlyN_
|
||||
[ alice <## ("#team: you removed " <> bobName <> " from the group (signed)"),
|
||||
do
|
||||
bob <## "#team: alice removed you from the group (signed)"
|
||||
bob <## "use /d #team to delete the group"
|
||||
]
|
||||
|
||||
testGroupLink :: HasCallStack => TestParams -> IO ()
|
||||
testGroupLink =
|
||||
testChatOpts3 testOptsNoFullLinks aliceProfile bobProfile cathProfile $
|
||||
@@ -10679,6 +10851,26 @@ testChannelRemoveMemberSigned ps =
|
||||
cath <## "group ID: 1"
|
||||
cath <## "subscribers: 2"
|
||||
|
||||
testChannelRemoveMemberNotice :: HasCallStack => TestParams -> IO ()
|
||||
testChannelRemoveMemberNotice ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChat ps "cath" cathProfile $ \cath -> do
|
||||
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
|
||||
memberJoinChannel "team" [bob] [alice] shortLink fullLink cath
|
||||
|
||||
threadDelay 1000000
|
||||
alice ##> "/rm #team cath notice={\"ttl\":86400,\"reason\":\"content\"}"
|
||||
alice <## "#team: you removed cath from the group (signed)"
|
||||
bob <## "#team: alice removed cath from the group (signed)"
|
||||
cath <## "#team: alice removed you from the group (signed)"
|
||||
cath <## "use /d #team to delete the group"
|
||||
|
||||
cath ##> ("/_connect plan 1 " <> shortLink)
|
||||
bannedLine cath "group link: " ", reason: content"
|
||||
cath ##> "/_connect group #1"
|
||||
bannedLine cath "connection link: " ", reason: content"
|
||||
|
||||
-- asserts the member row is GSMemRemoved, with removed_at set (TTL tombstone) or NULL (permanent)
|
||||
checkRemovedMember :: HasCallStack => TestCC -> String -> Bool -> Expectation
|
||||
checkRemovedMember cc name removedAtSet = do
|
||||
|
||||
@@ -79,6 +79,7 @@ chatProfileTests = do
|
||||
describe "business address" $ do
|
||||
it "create and connect via business address" testBusinessAddress
|
||||
it "update profiles with business address" testBusinessUpdateProfiles
|
||||
it "customer removed or chat deleted with notice can't re-connect" testBusinessAddressNotice
|
||||
describe "contact address connection plan" $ do
|
||||
it "contact address ok to connect; known contact" testPlanAddressOkKnown
|
||||
it "own contact address" testPlanAddressOwn
|
||||
@@ -86,6 +87,10 @@ chatProfileTests = do
|
||||
it "re-connect with deleted contact" testPlanAddressContactDeletedReconnected
|
||||
it "contact via address" testPlanAddressContactViaAddress
|
||||
it "contact via short address" testPlanAddressContactViaShortAddress
|
||||
it "contact deleted silently with notice can't re-connect" testPlanAddressContactDeletedNoticeSilent
|
||||
it "contact deleted with notification and notice can't re-connect" testPlanAddressContactDeletedNoticeNotify
|
||||
it "silent notice is not sent to previous version" $
|
||||
runTestCfg2 testCfg testCfgVPrev testPlanAddressContactDeletedNoticePrevVersion
|
||||
describe "incognito" $ do
|
||||
it "connect incognito via invitation link" testConnectIncognitoInvitationLink
|
||||
it "connect incognito via contact address" testConnectIncognitoContactAddress
|
||||
@@ -1452,6 +1457,49 @@ testBusinessUpdateProfiles = testChat4 businessProfile aliceProfile bobProfile c
|
||||
bob #$> ("/_get chat #1 count=1", chat, [(0, "Full deletion: on")])
|
||||
cath #$> ("/_get chat #1 count=1", chat, [(0, "Full deletion: on")])
|
||||
|
||||
testBusinessAddressNotice :: HasCallStack => TestParams -> IO ()
|
||||
testBusinessAddressNotice = testChat3 businessProfile bobProfile cathProfile $
|
||||
\biz bob cath -> do
|
||||
biz ##> "/ad"
|
||||
cLink <- getContactLink biz True
|
||||
biz ##> "/auto_accept on business"
|
||||
biz <## "auto_accept on, business"
|
||||
|
||||
connectBusinessCustomer biz bob cLink "bob" "Bob"
|
||||
biz ##> "/rm #bob bob_1 notice={\"ttl\":86400,\"reason\":\"spam\"}"
|
||||
biz <## "#bob: you removed bob_1 from the group (signed)"
|
||||
bob <## "#biz: biz_1 removed you from the group (signed)"
|
||||
bob <## "use /d #biz to delete the group"
|
||||
bob ##> ("/_connect plan 1 " <> cLink)
|
||||
bannedLine bob "contact address: " ", reason: spam"
|
||||
bob ##> ("/_connect 1 " <> cLink)
|
||||
bannedLine bob "connection link: " ", reason: spam"
|
||||
|
||||
connectBusinessCustomer biz cath cLink "cath" "Catherine"
|
||||
biz ##> "/_delete #2 full notice={\"reason\":\"other\"}"
|
||||
biz <## "#cath: you deleted the group (signed)"
|
||||
cath <## "#biz: biz_1 deleted the group (signed)"
|
||||
cath <## "use /d #biz to delete the local copy of the group"
|
||||
cath ##> ("/_connect plan 1 " <> cLink)
|
||||
cath <## "contact address: banned permanently, reason: other"
|
||||
cath ##> ("/c " <> cLink)
|
||||
cath <## "contact address: banned permanently, reason: other"
|
||||
cath ##> ("/_connect 1 " <> cLink)
|
||||
cath <## "connection link: banned permanently, reason: other"
|
||||
|
||||
connectBusinessCustomer :: HasCallStack => TestCC -> TestCC -> String -> String -> String -> IO ()
|
||||
connectBusinessCustomer biz customer cLink name fullName = do
|
||||
customer ##> ("/c " <> cLink)
|
||||
customer <## "connection request sent!"
|
||||
biz <## ("#" <> name <> " (" <> fullName <> "): accepting business address request...")
|
||||
concurrentlyN_
|
||||
[ biz <## ("#" <> name <> ": " <> name <> "_1 joined the group"),
|
||||
customer
|
||||
<### [ "#biz: joining the group...",
|
||||
"#biz: you joined the group"
|
||||
]
|
||||
]
|
||||
|
||||
testPlanAddressOkKnown :: HasCallStack => TestParams -> IO ()
|
||||
testPlanAddressOkKnown =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
@@ -1642,6 +1690,67 @@ testPlanAddressContactDeletedReconnected =
|
||||
bob <## "contact address: known contact alice_1"
|
||||
bob <## "use @alice_1 <message> to send messages"
|
||||
|
||||
testPlanAddressContactDeletedNoticeSilent :: HasCallStack => TestParams -> IO ()
|
||||
testPlanAddressContactDeletedNoticeSilent =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
cLink <- connectViaAddress alice bob
|
||||
|
||||
alice ##> "/_delete @2 full notify=off notice={\"ttl\":86400,\"reason\":\"spam\"}"
|
||||
alice <## "bob: contact is deleted"
|
||||
waitLinkNoticesCount bob 1
|
||||
|
||||
bob ##> ("/_connect plan 1 " <> cLink)
|
||||
bannedLine bob "contact address: " ", reason: spam"
|
||||
bob ##> ("/_connect plan 1 " <> linkAnotherSchema cLink)
|
||||
bannedLine bob "contact address: " ", reason: spam"
|
||||
bob ##> ("/c " <> cLink)
|
||||
bannedLine bob "contact address: " ", reason: spam"
|
||||
bob ##> ("/_connect 1 " <> cLink)
|
||||
bannedLine bob "connection link: " ", reason: spam"
|
||||
bob @@@ [("@alice", "hey")]
|
||||
|
||||
testPlanAddressContactDeletedNoticeNotify :: HasCallStack => TestParams -> IO ()
|
||||
testPlanAddressContactDeletedNoticeNotify =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
\alice bob -> do
|
||||
cLink <- connectViaAddress alice bob
|
||||
|
||||
alice ##> "/_delete @2 full notice={\"ttl\":86400}"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
|
||||
bob ##> ("/_connect plan 1 " <> cLink)
|
||||
bannedLine bob "contact address: " ""
|
||||
linkNoticesCount bob `shouldReturn` 1
|
||||
|
||||
testPlanAddressContactDeletedNoticePrevVersion :: HasCallStack => TestCC -> TestCC -> IO ()
|
||||
testPlanAddressContactDeletedNoticePrevVersion alice bob = do
|
||||
cLink <- connectViaAddress alice bob
|
||||
|
||||
alice ##> "/_delete @2 full notify=off notice={\"ttl\":86400}"
|
||||
alice <## "bob: contact is deleted"
|
||||
threadDelay 500000
|
||||
|
||||
bob ##> ("/_connect plan 1 " <> cLink)
|
||||
bob <## "contact address: known contact alice"
|
||||
bob <## "use @alice <message> to send messages"
|
||||
linkNoticesCount bob `shouldReturn` 0
|
||||
|
||||
connectViaAddress :: HasCallStack => TestCC -> TestCC -> IO String
|
||||
connectViaAddress alice bob = do
|
||||
alice ##> "/ad"
|
||||
cLink <- getContactLink alice True
|
||||
bob ##> ("/c " <> cLink)
|
||||
alice <#? bob
|
||||
alice ##> "/ac bob"
|
||||
alice <## "bob (Bob): accepting contact request, you can send messages to contact"
|
||||
concurrently_
|
||||
(bob <## "alice (Alice): contact is connected")
|
||||
(alice <## "bob (Bob): contact is connected")
|
||||
alice <##> bob
|
||||
pure cLink
|
||||
|
||||
testPlanAddressContactViaAddress :: HasCallStack => TestParams -> IO ()
|
||||
testPlanAddressContactViaAddress =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
|
||||
@@ -680,6 +680,27 @@ createCCNoteFolder cc =
|
||||
withCCUser cc $ \user ->
|
||||
runExceptT (createNoteFolder db user) >>= either (fail . show) pure
|
||||
|
||||
linkNoticesCount :: TestCC -> IO Int
|
||||
linkNoticesCount cc =
|
||||
withCCTransaction cc $ \db ->
|
||||
fromMaybe 0 <$> maybeFirstRow fromOnly (DB.query_ db "SELECT count(1) FROM link_notices")
|
||||
|
||||
waitLinkNoticesCount :: HasCallStack => TestCC -> Int -> Expectation
|
||||
waitLinkNoticesCount cc n = go (50 :: Int)
|
||||
where
|
||||
go attempts = do
|
||||
count <- linkNoticesCount cc
|
||||
if count == n || attempts <= 0
|
||||
then count `shouldBe` n
|
||||
else threadDelay 100000 >> go (attempts - 1)
|
||||
|
||||
bannedLine :: HasCallStack => TestCC -> String -> String -> Expectation
|
||||
bannedLine cc prefix suffix = do
|
||||
l <- getTermLine' (Just $ "banned: " <> prefix) cc
|
||||
let banned = (prefix <> "banned until ") `isPrefixOf` l && suffix `isSuffixOf` l
|
||||
unless banned $ print ("expected banned line: " <> prefix <> "... " <> suffix, ", got: " <> l)
|
||||
banned `shouldBe` True
|
||||
|
||||
getProfilePictureByName :: TestCC -> String -> IO (Maybe String)
|
||||
getProfilePictureByName cc displayName =
|
||||
withTransaction (chatStore $ chatController cc) $ \db ->
|
||||
|
||||
+30
-6
@@ -225,7 +225,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello")))
|
||||
it "x.msg.new chat message with chat version range" $
|
||||
"{\"v\":\"9-20\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
"{\"v\":\"9-21\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
##==## ChatMessage supportedChatVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (mcSimple (MCText "hello")))
|
||||
it "x.msg.new quote" $
|
||||
"{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}"
|
||||
@@ -281,6 +281,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
chatInitialVRange
|
||||
(Just $ SharedMsgId "\1\2\3\4")
|
||||
(XMsgNew (mcQuote quotedMsg (MCReport "" RRSpam)))
|
||||
it "x.msg.new report with illegal reason" $
|
||||
"{\"v\":\"9\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"reason\":\"illegal\",\"type\":\"report\"}}}"
|
||||
==# XMsgNew (mcSimple (MCReport "" RRContent))
|
||||
it "x.msg.new forward with file" $
|
||||
"{\"v\":\"9\",\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"},\"forward\":true}}"
|
||||
##==## ChatMessage chatInitialVRange (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ (mcForward Nothing (MCText "hello")) {file = Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileDigest = Nothing, fileConnReq = Nothing, fileInline = Nothing, fileDescr = Nothing, fileBadge = Nothing}})
|
||||
@@ -342,13 +345,13 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing
|
||||
it "x.grp.mem.new with member chat version range" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing
|
||||
it "x.grp.mem.intro" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} Nothing
|
||||
it "x.grp.mem.intro with member chat version range" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} Nothing
|
||||
it "x.grp.mem.intro with member restrictions" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.intro\",\"params\":{\"memberRestrictions\":{\"restriction\":\"blocked\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
@@ -363,7 +366,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Nothing, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Just testConnReq}
|
||||
it "x.grp.mem.fwd with member chat version range and w/t directConnReq" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-20\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"groupConnReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"v\":\"9-21\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}"
|
||||
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, v = Just $ ChatVersionRange supportedChatVRange, profile = testProfile, memberKey = Nothing} IntroInvitation {groupConnReq = testConnReq, directConnReq = Nothing}
|
||||
it "x.grp.mem.info" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
|
||||
@@ -376,13 +379,34 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
|
||||
#==# XGrpMemConAll (MemberId "\1\2\3\4")
|
||||
it "x.grp.mem.del" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}"
|
||||
#==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing
|
||||
#==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing Nothing
|
||||
it "x.grp.mem.del with notice" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\",\"notice\":{\"ttl\":86400,\"reason\":\"spam\"}}}"
|
||||
#==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing (Just LinkNotice {ttl = Just 86400, reason = Just RRSpam})
|
||||
it "x.grp.mem.del with malformed notice" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\",\"notice\":{\"ttl\":\"day\"}}}"
|
||||
==# XGrpMemDel (MemberId "\1\2\3\4") False Nothing Nothing
|
||||
it "x.grp.leave" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.leave\",\"params\":{}}"
|
||||
==# XGrpLeave
|
||||
it "x.grp.del" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{}}"
|
||||
==# XGrpDel
|
||||
==# XGrpDel Nothing
|
||||
it "x.grp.del with notice" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{\"notice\":{\"reason\":\"content\"}}}"
|
||||
#==# XGrpDel (Just LinkNotice {ttl = Nothing, reason = Just RRContent})
|
||||
it "x.grp.del with malformed notice" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.del\",\"params\":{\"notice\":1}}"
|
||||
==# XGrpDel Nothing
|
||||
it "x.direct.del" $
|
||||
"{\"v\":\"9\",\"event\":\"x.direct.del\",\"params\":{}}"
|
||||
#==# XDirectDel False Nothing
|
||||
it "x.direct.del silent with notice" $
|
||||
"{\"v\":\"9\",\"event\":\"x.direct.del\",\"params\":{\"silent\":true,\"notice\":{\"ttl\":3600}}}"
|
||||
#==# XDirectDel True (Just LinkNotice {ttl = Just 3600, reason = Nothing})
|
||||
it "x.direct.del with malformed notice" $
|
||||
"{\"v\":\"9\",\"event\":\"x.direct.del\",\"params\":{\"notice\":\"spam\"}}"
|
||||
==# XDirectDel False Nothing
|
||||
it "x.grp.direct.inv" $
|
||||
"{\"v\":\"9\",\"event\":\"x.grp.direct.inv\",\"params\":{\"connReq\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}"
|
||||
#==# XGrpDirectInv testConnReq (Just $ MCText "hello") Nothing
|
||||
|
||||
Reference in new issue
Block a user