core: link notices

This commit is contained in:
Evgeny @ SimpleX Chat committed 2026-09-27 16:15:58 +00:00
1 parent 3ed4511e80
commit 40fc230f06
50 files changed
+1329 -353

No files matched your search

+16 -5
View File
@@ -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)
+21 -13
View File
@@ -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
}
+12 -4
View File
@@ -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
}
+6 -2
View File
@@ -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))")
+1
View File
@@ -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)
+18 -13
View File
@@ -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
@@ -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"
@@ -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()
@@ -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) {
@@ -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)
}
)
)
}
@@ -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)
@@ -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) {
@@ -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) {
@@ -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 ()
+8 -6
View File
@@ -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**:
+25
View File
@@ -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
+2 -2
View File
@@ -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"),
+2
View File
@@ -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
+8 -2
View File
@@ -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)
}
+4 -4
View File
@@ -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
+2
View File
@@ -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:
+6 -3
View File
@@ -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}
+50 -24
View File
@@ -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),
+34 -29
View File
@@ -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
+29 -13
View File
@@ -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);
+3 -1
View File
@@ -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
+50 -1
View File
@@ -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)
+8
View File
@@ -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)]
+13
View File
@@ -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 $
+192
View File
@@ -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
+109
View File
@@ -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 $
+21
View File
@@ -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
View File
@@ -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