ios: reports inline (#5466)

* initial types

* changes types

* decode

* possible mock for inline report

* remove avatar

* diff

* updates

* parser and display message

* send messages and support placeholder

* profile reports and all reports working

* new api

* check member support for receiving reports

* report chat item text

* moderator role

* placeholder on text compose for report

* rename method

* remove need to have reported item in memory to action

* archived reports

* changes/fix

* fix block member

* delete and moderate

* archive

* report reason

* context menu/moderation fixes

* typo

* not needed

* report reason as caption, and change text

* remove auto archive

* move placeholder to match text

* prefix red italic report

* archive

* apply mark deleted fix

* Revert "apply mark deleted fix"

This reverts commit b12f14c0f5.

* remove extra space

* context menu rework

* strings, icons

* recheck items extra check on reports

* simplify

* simpler

* reports: never show for own messages, disable attachments, hide when recording or live

* style, allow local deletion

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Diogo 2025-01-08 18:28:45 +00:00 committed by GitHub
parent 8dc29082d5
commit 7e344b3ee8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 617 additions and 99 deletions

View file

@ -15,12 +15,6 @@ import SimpleXChat
private var chatController: chat_ctrl?
// currentChatVersion in core
public let CURRENT_CHAT_VERSION: Int = 2
// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core)
public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION)
private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock")
enum TerminalItem: Identifiable {
@ -418,6 +412,18 @@ func apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage]
return nil
}
func apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String) async -> [ChatItem]? {
let r = await chatSendCmd(.apiReportMessage(groupId: groupId, chatItemId: chatItemId, reportReason: reportReason, reportText: reportText))
if case let .newChatItems(_, aChatItems) = r { return aChatItems.map { $0.chatItem } }
logger.error("apiReportMessage error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating report",
message: "Error: \(responseError(r))"
)
return nil
}
private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(

View file

@ -30,7 +30,17 @@ struct FramedItemView: View {
var body: some View {
let v = ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 0) {
if let di = chatItem.meta.itemDeleted {
if chatItem.isReport {
if chatItem.meta.itemDeleted == nil {
let txt = chatItem.chatDir.sent ?
Text("Only you and moderators see it") :
Text("Only sender and moderators see it")
framedItemHeader(icon: "flag", iconColor: .red, caption: txt.italic())
} else {
framedItemHeader(icon: "flag", caption: Text("archived report").italic())
}
} else if let di = chatItem.meta.itemDeleted {
switch di {
case let .moderated(_, byGroupMember):
framedItemHeader(icon: "flag", caption: Text("moderated by \(byGroupMember.displayName)").italic())
@ -144,6 +154,8 @@ struct FramedItemView: View {
}
case let .file(text):
ciFileView(chatItem, text)
case let .report(text, reason):
ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red))
case let .link(_, preview):
CILinkView(linkPreview: preview)
ciMsgContentView(chatItem)
@ -159,13 +171,14 @@ struct FramedItemView: View {
}
}
@ViewBuilder func framedItemHeader(icon: String? = nil, caption: Text, pad: Bool = false) -> some View {
@ViewBuilder func framedItemHeader(icon: String? = nil, iconColor: Color? = nil, caption: Text, pad: Bool = false) -> some View {
let v = HStack(spacing: 6) {
if let icon = icon {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 14, height: 14)
.foregroundColor(iconColor ?? theme.colors.secondary)
}
caption
.font(.caption)
@ -228,7 +241,6 @@ struct FramedItemView: View {
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
.frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, theme))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading)
} else {
@ -281,7 +293,7 @@ struct FramedItemView: View {
}
}
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem) -> some View {
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View {
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text)
let ft = text == "" ? [] : ci.formattedText
@ -291,7 +303,8 @@ struct FramedItemView: View {
formattedText: ft,
meta: ci.meta,
rightToLeft: rtl,
showSecrets: showSecrets
showSecrets: showSecrets,
prefix: txtPrefix
))
.multilineTextAlignment(rtl ? .trailing : .leading)
.padding(.vertical, 6)

View file

@ -67,11 +67,15 @@ struct MarkedDeletedItemView: View {
// same texts are in markedDeletedText in ChatPreviewView, but it returns String;
// can be refactored into a single function if functions calling these are changed to return same type
var markedDeletedText: LocalizedStringKey {
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked"
case .blockedByAdmin: "blocked by admin"
case .deleted, nil: "marked deleted"
if chatItem.meta.itemDeleted != nil, chatItem.isReport {
"archived report"
} else {
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked"
case .blockedByAdmin: "blocked by admin"
case .deleted, nil: "marked deleted"
}
}
}
}

View file

@ -34,6 +34,7 @@ struct MsgContentView: View {
var meta: CIMeta? = nil
var rightToLeft = false
var showSecrets: Bool
var prefix: Text? = nil
@State private var typingIdx = 0
@State private var timer: Timer?
@ -67,7 +68,7 @@ struct MsgContentView: View {
}
private func msgContentView() -> Text {
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)
var v = messageText(text, formattedText, sender, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix)
if let mt = meta {
if mt.isLive {
v = v + typingIndicator(mt.recent)
@ -89,9 +90,10 @@ struct MsgContentView: View {
}
}
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color) -> Text {
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text {
let s = text
var res: Text
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
res = formatText(ft[0], preview, showSecret: showSecrets)
var i = 1
@ -106,6 +108,10 @@ func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: St
if let i = icon {
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res
}
if let p = prefix {
res = p + res
}
if let s = sender {
let t = Text(s)

View file

@ -917,6 +917,7 @@ struct ChatView: View {
@State private var allowMenu: Bool = true
@State private var markedRead = false
@State private var actionSheet: SomeActionSheet? = nil
var revealed: Bool { chatItem == revealedChatItem }
@ -1001,6 +1002,7 @@ struct ChatView: View {
}
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
}
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
@ -1208,7 +1210,7 @@ struct ChatView: View {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal, moderate: false)
}
if let di = deletingItem, di.meta.deletable && !di.localNote {
if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport {
Button(broadcastDeleteButtonText(chat), role: .destructive) {
deleteMessage(.cidmBroadcast, moderate: false)
}
@ -1282,7 +1284,21 @@ struct ChatView: View {
@ViewBuilder
private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> some View {
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
if let groupInfo = chat.chatInfo.groupInfo, ci.isReport, ci.meta.itemDeleted == nil {
if ci.chatDir == .groupSnd {
deleteButton(ci)
} else {
archiveReportButton(ci)
if let qi = ci.quotedItem {
moderateReportedButton(qi, ci, groupInfo)
if let rMember = qi.memberToModerate(chat.chatInfo) {
if !rMember.blockedByAdmin, rMember.canBlockForAll(groupInfo: groupInfo) {
blockMemberButton(rMember, groupInfo, qi, ci)
}
}
}
}
} else if let mc = ci.content.msgContent, !ci.isReport, ci.meta.itemDeleted == nil || revealed {
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
availableReactions.count > 0 {
reactionsGroup
@ -1332,8 +1348,12 @@ struct ChatView: View {
if !live || !ci.meta.isLive {
deleteButton(ci)
}
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd {
moderateButton(ci, groupInfo)
if ci.chatDir != .groupSnd {
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
moderateButton(ci, groupInfo)
} else if ci.meta.itemDeleted == nil, case let .group(gInfo) = chat.chatInfo, gInfo.membership.memberRole < .moderator, !live, composeState.voiceMessageRecordingState == .noRecording {
reportButton(ci)
}
}
} else if ci.meta.itemDeleted != nil {
if revealed {
@ -1648,19 +1668,10 @@ struct ChatView: View {
private func moderateButton(_ ci: ChatItem, _ groupInfo: GroupInfo) -> Button<some View> {
Button(role: .destructive) {
AlertManager.shared.showAlert(Alert(
title: Text("Delete member message?"),
message: Text(
groupInfo.fullGroupPreferences.fullDelete.on
? "The message will be deleted for all members."
: "The message will be marked as moderated for all members."
),
primaryButton: .destructive(Text("Delete")) {
deletingItem = ci
deleteMessage(.cidmBroadcast, moderate: true)
},
secondaryButton: .cancel()
))
showModerateMessageAlert(groupInfo) {
deletingItem = ci
deleteMessage(.cidmBroadcast, moderate: true)
}
} label: {
Label(
NSLocalizedString("Moderate", comment: "chat item action"),
@ -1668,6 +1679,112 @@ struct ChatView: View {
)
}
}
private func moderateReportedButton(_ rItem: CIQuote, _ reportItem: ChatItem, _ groupInfo: GroupInfo) -> Button<some View> {
Button(role: .destructive) {
showModerateMessageAlert(groupInfo) {
Task {
let deleted = await deleteReportedMessage(rItem, reportItem.id, groupInfo)
if deleted != nil {
await MainActor.run {
deletingItem = reportItem
deleteMessage(.cidmInternalMark, moderate: false)
}
}
}
}
} label: {
Label(
NSLocalizedString("Moderate", comment: "chat item action"),
systemImage: "flag"
)
}
}
private func archiveReportButton(_ cItem: ChatItem) -> Button<some View> {
Button(role: .destructive) {
AlertManager.shared.showAlert(
Alert(
title: Text("Archive report?"),
message: Text("The report will be archived for all moderators and reporter."),
primaryButton: .destructive(Text("Archive")) {
deletingItem = cItem
deleteMessage(.cidmInternalMark, moderate: false)
},
secondaryButton: .cancel()
)
)
} label: {
Label(
NSLocalizedString("Archive", comment: "chat item action"),
systemImage: "archivebox"
)
}
}
private func blockMemberButton(_ member: GroupMember, _ groupInfo: GroupInfo, _ rItem: CIQuote, _ report: ChatItem) -> Button<some View> {
Button(role: .destructive) {
actionSheet = SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Block and moderate?"),
buttons: [
.destructive(Text("Block and moderate")) {
AlertManager.shared.showAlert(
Alert(
title: Text("Delete member message and block?"),
message: Text(
NSLocalizedString(
groupInfo.fullGroupPreferences.fullDelete.on
? "The message will be deleted for all members.\nAll new messages from \(member.chatViewName) will be hidden!"
: "The message will be marked as moderated for all members.\n All new messages from \(member.chatViewName) will be hidden!"
, comment: "block and moderate action"
)
),
primaryButton: .destructive(Text("Delete and block")) {
Task {
let deleted = await deleteReportedMessage(rItem, report.id, groupInfo)
if deleted != nil {
let blocked = await blockMemberForAll(groupInfo, member, true)
if blocked != nil {
await MainActor.run {
deletingItem = report
deleteMessage(.cidmInternalMark, moderate: false)
}
}
}
}
},
secondaryButton: .cancel()
)
)
},
.destructive(Text("Only block")) {
Task {
if (await getLocalIdForReportedMessage(rItem, report.id, groupInfo)) != nil {
AlertManager.shared.showAlert(
blockForAllAlert(groupInfo, member) {
deletingItem = report
deleteMessage(.cidmInternalMark, moderate: false)
}
)
} else {
showNoMessageMessageAlert()
}
}
},
.cancel()
]
),
id: "blockMember"
)
} label: {
Label(
NSLocalizedString("Block member", comment: "chat item action"),
systemImage: "hand.raised"
)
}
}
private func revealButton(_ ci: ChatItem) -> Button<some View> {
Button {
@ -1707,7 +1824,38 @@ struct ChatView: View {
)
}
}
private func reportButton(_ ci: ChatItem) -> Button<some View> {
Button(role: .destructive) {
var buttons: [ActionSheet.Button] = ReportReason.supportedReasons.map { reason in
.default(Text(reason.text)) {
withAnimation {
if composeState.editing {
composeState = ComposeState(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason))
} else {
composeState = composeState.copy(preview: .noPreview, contextItem: .reportedItem(chatItem: chatItem, reason: reason))
}
}
}
}
buttons.append(.cancel())
actionSheet = SomeActionSheet(
actionSheet: ActionSheet(
title: Text("Report reason?"),
buttons: buttons
),
id: "reportChatMessage"
)
} label: {
Label (
NSLocalizedString("Report", comment: "chat item action"),
systemImage: "flag"
)
}
}
var deleteMessagesTitle: LocalizedStringKey {
let n = deletingItems.count
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
@ -1738,6 +1886,60 @@ struct ChatView: View {
itemIds.forEach { selectedChatItems?.remove($0) }
}
}
private func deleteReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> ChatItemDeletion? {
do {
let itemId = await getLocalIdForReportedMessage(rItem, reportId, groupInfo)
if let itemId = itemId {
let deletedItem = try await apiDeleteMemberChatItems(
groupId: groupInfo.apiId,
itemIds: [itemId]
).first
if let di = deletedItem {
await MainActor.run {
if let toItem = di.toChatItem {
_ = m.upsertChatItem(chat.chatInfo, toItem.chatItem)
} else {
m.removeChatItem(chat.chatInfo, di.deletedChatItem.chatItem)
}
}
return di
}
} else {
showNoMessageMessageAlert()
}
} catch {
logger.error("ChatView.deleteReportedMessage error: \(error)")
AlertManager.shared.showAlertMsg(title: LocalizedStringKey("Error"), message: LocalizedStringKey("Failed to delete reported message"))
}
return nil
}
private func getLocalIdForReportedMessage(_ rItem: CIQuote, _ reportId: Int64, _ groupInfo: GroupInfo) async -> Int64? {
do {
if let itemId = rItem.itemId {
return itemId
} else {
let reportItem = try await apiGetChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
pagination: .around(chatItemId: reportId, count: 0)
).first
if let itemId = reportItem?.quotedItem?.itemId {
return itemId
}
}
} catch {
logger.error("ChatView.getLocalIdForReportedMessage error: \(error)")
}
return nil
}
private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) {
logger.debug("ChatView deleteMessage")
@ -1772,7 +1974,7 @@ struct ChatView: View {
}
}
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
logger.error("ChatView.deleteMessage error: \(error)")
}
}
}
@ -1812,6 +2014,26 @@ struct ChatView: View {
}
}
private func showModerateMessageAlert(_ groupInfo: GroupInfo, _ onModerate: @escaping () -> Void) {
AlertManager.shared.showAlert(Alert(
title: Text("Delete member message?"),
message: Text(
groupInfo.fullGroupPreferences.fullDelete.on
? "The message will be deleted for all members."
: "The message will be marked as moderated for all members."
),
primaryButton: .destructive(Text("Delete"), action: onModerate),
secondaryButton: .cancel()
))
}
private func showNoMessageMessageAlert() {
AlertManager.shared.showAlertMsg(
title: LocalizedStringKey("No message"),
message: LocalizedStringKey("This message was deleted or not received yet.")
)
}
private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}

View file

@ -24,6 +24,7 @@ enum ComposeContextItem {
case quotedItem(chatItem: ChatItem)
case editingItem(chatItem: ChatItem)
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
case reportedItem(chatItem: ChatItem, reason: ReportReason)
}
enum VoiceMessageRecordingState {
@ -116,13 +117,31 @@ struct ComposeState {
default: return false
}
}
var reporting: Bool {
switch contextItem {
case .reportedItem: return true
default: return false
}
}
var submittingValidReport: Bool {
switch contextItem {
case let .reportedItem(_, reason):
switch reason {
case .other: return !message.isEmpty
default: return true
}
default: return false
}
}
var sendEnabled: Bool {
switch preview {
case let .mediaPreviews(media): return !media.isEmpty
case .voicePreview: return voiceMessageRecordingState == .finished
case .filePreview: return true
default: return !message.isEmpty || forwarding || liveMessage != nil
default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport
}
}
@ -175,7 +194,7 @@ struct ComposeState {
}
var attachmentDisabled: Bool {
if editing || forwarding || liveMessage != nil || inProgress { return true }
if editing || forwarding || liveMessage != nil || inProgress || reporting { return true }
switch preview {
case .noPreview: return false
case .linkPreview: return false
@ -193,6 +212,15 @@ struct ComposeState {
}
}
var placeholder: String? {
switch contextItem {
case let .reportedItem(_, reason):
return reason.text
default:
return nil
}
}
var empty: Bool {
message == "" && noPreview
}
@ -297,6 +325,11 @@ struct ComposeView: View {
ContextInvitingContactMemberView()
Divider()
}
if case let .reportedItem(_, reason) = composeState.contextItem {
reportReasonView(reason)
Divider()
}
// preference checks should match checks in forwarding list
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
@ -686,6 +719,27 @@ struct ComposeView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
private func reportReasonView(_ reason: ReportReason) -> some View {
let reportText = switch reason {
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 .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason")
case .unknown: "" // Should never happen
}
return Text(reportText)
.italic()
.font(.caption)
.padding(12)
.frame(minHeight: 44)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.thinMaterial)
}
@ViewBuilder private func contextItemView() -> some View {
switch composeState.contextItem {
@ -715,6 +769,15 @@ struct ComposeView: View {
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
Divider()
case let .reportedItem(chatItem: reportedItem, _):
ContextItemView(
chat: chat,
contextItems: [reportedItem],
contextIcon: "flag",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
contextIconForeground: Color.red
)
Divider()
}
}
@ -746,6 +809,8 @@ struct ComposeView: View {
sent = await updateMessage(ci, live: live)
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
sent = await updateMessage(liveMessage.chatItem, live: live)
} else if case let .reportedItem(chatItem, reason) = composeState.contextItem {
sent = await send(reason, chatItemId: chatItem.id)
} else {
var quoted: Int64? = nil
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
@ -872,6 +937,8 @@ struct ComposeView: View {
return .voice(text: msgText, duration: duration)
case .file:
return .file(msgText)
case .report(_, let reason):
return .report(text: msgText, reason: reason)
case .unknown(let type, _):
return .unknown(type: type, text: msgText)
}
@ -891,7 +958,25 @@ struct ComposeView: View {
return nil
}
}
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
if let chatItems = await apiReportMessage(
groupId: chat.chatInfo.apiId,
chatItemId: chatItemId,
reportReason: reportReason,
reportText: msgText
) {
await MainActor.run {
for chatItem in chatItems {
chatModel.addChatItem(chat.chatInfo, chatItem)
}
}
return chatItems.first
}
return nil
}
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
await send(
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],

View file

@ -15,6 +15,7 @@ struct ContextItemView: View {
let contextItems: [ChatItem]
let contextIcon: String
let cancelContextItem: () -> Void
var contextIconForeground: Color? = nil
var showSender: Bool = true
var body: some View {
@ -23,7 +24,7 @@ struct ContextItemView: View {
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 16, height: 16)
.foregroundColor(theme.colors.secondary)
.foregroundColor(contextIconForeground ?? theme.colors.secondary)
if let singleItem = contextItems.first, contextItems.count == 1 {
if showSender, let sender = singleItem.memberDisplayName {
VStack(alignment: .leading, spacing: 4) {
@ -93,6 +94,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {})
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red)
}
}

View file

@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable {
@Binding var disableEditing: Bool
@Binding var height: CGFloat
@Binding var focused: Bool
@Binding var placeholder: String?
let onImagesAdded: ([UploadContent]) -> Void
private let minHeight: CGFloat = 37
@ -50,6 +51,7 @@ struct NativeTextEditor: UIViewRepresentable {
field.setOnFocusChangedListener { focused = $0 }
field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
field.setPlaceholderView()
updateFont(field)
updateHeight(field)
return field
@ -62,6 +64,11 @@ struct NativeTextEditor: UIViewRepresentable {
updateFont(field)
updateHeight(field)
}
let castedField = field as! CustomUITextField
if castedField.placeholder != placeholder {
castedField.placeholder = placeholder
}
}
private func updateHeight(_ field: UITextView) {
@ -97,11 +104,18 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in }
private let placeholderLabel: UILabel = UILabel()
init(height: Binding<CGFloat>) {
self.height = height
super.init(frame: .zero, textContainer: nil)
}
var placeholder: String? {
get { placeholderLabel.text }
set { placeholderLabel.text = newValue }
}
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
@ -124,6 +138,20 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
self.onTextChanged = onTextChanged
}
func setPlaceholderView() {
placeholderLabel.textColor = .lightGray
placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body)
placeholderLabel.isHidden = !text.isEmpty
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
addSubview(placeholderLabel)
NSLayoutConstraint.activate([
placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7),
placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7),
placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8)
])
}
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
self.onFocusChanged = onFocusChanged
@ -172,6 +200,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
}
func textViewDidChange(_ textView: UITextView) {
placeholderLabel.isHidden = !text.isEmpty
if textView.markedTextRange == nil {
var images: [UploadContent] = []
var rangeDiff = 0
@ -217,6 +246,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
disableEditing: Binding.constant(false),
height: Binding.constant(100),
focused: Binding.constant(false),
placeholder: Binding.constant("Placeholder"),
onImagesAdded: { _ in }
)
.fixedSize(horizontal: false, vertical: true)

View file

@ -61,6 +61,7 @@ struct SendMessageView: View {
disableEditing: $composeState.inProgress,
height: $teHeight,
focused: $keyboardVisible,
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
onImagesAdded: onMediaAdded
)
.allowsTightening(false)
@ -105,6 +106,8 @@ struct SendMessageView: View {
let vmrs = composeState.voiceMessageRecordingState
if nextSendGrpInv {
inviteMemberContactButton()
} else if case .reportedItem = composeState.contextItem {
sendMessageButton()
} else if showVoiceMessageButton
&& composeState.message.isEmpty
&& !composeState.editing

View file

@ -175,10 +175,8 @@ struct AddGroupMembersViewCommon: View {
private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.allCases) { role in
if role <= groupInfo.membership.memberRole && role != .author {
Text(role.text)
}
ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in
Text(role.text)
}
}
.frame(height: 36)

View file

@ -296,7 +296,7 @@ struct GroupMemberInfoView: View {
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
} else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION {
createMemberContactButton(width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
@ -764,12 +764,18 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet
}
}
func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
func blockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember, _ onBlocked: (() -> Void)? = nil) -> Alert {
Alert(
title: Text("Block member for all?"),
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
primaryButton: .destructive(Text("Block for all")) {
blockMemberForAll(gInfo, mem, true)
Task {
let uMember = await blockMemberForAll(gInfo, mem, true)
if uMember != nil {
onBlocked?()
}
}
},
secondaryButton: .cancel()
)
@ -780,23 +786,25 @@ func unblockForAllAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
title: Text("Unblock member for all?"),
message: Text("Messages from \(mem.chatViewName) will be shown!"),
primaryButton: .default(Text("Unblock for all")) {
blockMemberForAll(gInfo, mem, false)
Task {
await blockMemberForAll(gInfo, mem, false)
}
},
secondaryButton: .cancel()
)
}
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) {
Task {
do {
let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
}
} catch let error {
logger.error("apiBlockMemberForAll error: \(responseError(error))")
func blockMemberForAll(_ gInfo: GroupInfo, _ member: GroupMember, _ blocked: Bool) async -> GroupMember? {
do {
let updatedMember = try await apiBlockMemberForAll(gInfo.groupId, member.groupMemberId, blocked)
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(gInfo, updatedMember)
}
return updatedMember
} catch let error {
logger.error("apiBlockMemberForAll error: \(responseError(error))")
}
return nil
}
struct GroupMemberInfoView_Previews: PreviewProvider {

View file

@ -116,10 +116,10 @@ struct SelectedItemsBottomToolbar: View {
if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, fe, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy
dee = dee && ci.meta.deletable && !ci.localNote && !ci.isReport
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd && !ci.isReport
me = me && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil && !ci.isReport
fe = fe && ci.content.msgContent != nil && ci.meta.itemDeleted == nil && !ci.isLiveDummy && !ci.isReport
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, me, onlyOwnGroupItems, fe, sel)
} else {

View file

@ -248,16 +248,20 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix())
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type
func markedDeletedText() -> String {
switch cItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
if cItem.meta.itemDeleted != nil, cItem.isReport {
"archived report"
} else {
switch cItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): String.localizedStringWithFormat(NSLocalizedString("moderated by %@", comment: "marked deleted chat item preview text"), byGroupMember.displayName)
case .blocked: NSLocalizedString("blocked", comment: "marked deleted chat item preview text")
case .blockedByAdmin: NSLocalizedString("blocked by admin", comment: "marked deleted chat item preview text")
case .deleted, nil: NSLocalizedString("marked deleted", comment: "marked deleted chat item preview text")
}
}
}
@ -270,6 +274,13 @@ struct ChatPreviewView: View {
default: return nil
}
}
func prefix() -> Text {
switch cItem.content.msgContent {
case let .report(text, reason): return Text(!text.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red)
default: return Text("")
}
}
}
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {

View file

@ -45,6 +45,7 @@ public enum ChatCommand {
case apiGetChatItemInfo(type: ChatType, id: Int64, itemId: Int64)
case apiSendMessages(type: ChatType, id: Int64, live: Bool, ttl: Int?, composedMessages: [ComposedMessage])
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
@ -209,6 +210,8 @@ public enum ChatCommand {
case let .apiCreateChatItems(noteFolderId, composedMessages):
let msgs = encodeJSON(composedMessages)
return "/_create *\(noteFolderId) json \(msgs)"
case let .apiReportMessage(groupId, chatItemId, reportReason, reportText):
return "/_report #\(groupId) \(chatItemId) reason=\(reportReason) \(reportText)"
case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)"
case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
@ -372,6 +375,7 @@ public enum ChatCommand {
case .apiGetChatItemInfo: return "apiGetChatItemInfo"
case .apiSendMessages: return "apiSendMessages"
case .apiCreateChatItems: return "apiCreateChatItems"
case .apiReportMessage: return "apiReportMessage"
case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem"
case .apiConnectContactViaAddress: return "apiConnectContactViaAddress"
@ -1162,12 +1166,14 @@ public enum ChatPagination {
case last(count: Int)
case after(chatItemId: Int64, count: Int)
case before(chatItemId: Int64, count: Int)
case around(chatItemId: Int64, count: Int)
var cmdString: String {
switch self {
case let .last(count): return "count=\(count)"
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
}
}
}

View file

@ -9,6 +9,12 @@
import Foundation
import SwiftUI
// version to establishing direct connection with a group member (xGrpDirectInvVRange in core)
public let CREATE_MEMBER_CONTACT_VERSION = 2
// version to receive reports (MCReport)
public let REPORTS_VERSION = 12
public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
public var userId: Int64
public var agentUserId: String
@ -1678,7 +1684,7 @@ public struct Connection: Decodable, Hashable {
static let sampleData = Connection(
connId: 1,
agentConnId: "abc",
peerChatVRange: VersionRange(minVersion: 1, maxVersion: 1),
peerChatVRange: VersionRange(1, 1),
connStatus: .ready,
connLevel: 0,
viaGroupLink: false,
@ -1690,17 +1696,13 @@ public struct Connection: Decodable, Hashable {
}
public struct VersionRange: Decodable, Hashable {
public init(minVersion: Int, maxVersion: Int) {
public init(_ minVersion: Int, _ maxVersion: Int) {
self.minVersion = minVersion
self.maxVersion = maxVersion
}
public var minVersion: Int
public var maxVersion: Int
public func isCompatibleRange(_ vRange: VersionRange) -> Bool {
self.minVersion <= vRange.maxVersion && vRange.minVersion <= self.maxVersion
}
}
public struct SecurityCode: Decodable, Equatable, Hashable {
@ -1752,7 +1754,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable {
public static let sampleData = UserContactRequest(
contactRequestId: 1,
userContactLinkId: 1,
cReqChatVRange: VersionRange(minVersion: 1, maxVersion: 1),
cReqChatVRange: VersionRange(1, 1),
localDisplayName: "alice",
profile: Profile.sampleData,
createdAt: .now,
@ -1989,6 +1991,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var memberContactId: Int64?
public var memberContactProfileId: Int64
public var activeConn: Connection?
public var memberChatVRange: VersionRange
public var id: String { "#\(groupId) @\(groupMemberId)" }
public var displayName: String {
@ -2083,7 +2086,19 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
return memberStatus != .memRemoved && memberStatus != .memLeft && memberRole < .admin
&& userRole >= .admin && userRole >= memberRole && groupInfo.membership.memberActive
}
public var canReceiveReports: Bool {
memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION
}
public var versionRange: VersionRange {
if let activeConn {
activeConn.peerChatVRange
} else {
memberChatVRange
}
}
public var memberIncognito: Bool {
memberProfile.profileId != memberContactProfileId
}
@ -2102,7 +2117,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
memberProfile: LocalProfile.sampleData,
memberContactId: 1,
memberContactProfileId: 1,
activeConn: Connection.sampleData
activeConn: Connection.sampleData,
memberChatVRange: VersionRange(2, 12)
)
}
@ -2121,19 +2137,23 @@ public struct GroupMemberIds: Decodable, Hashable {
}
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Codable, Hashable {
case observer = "observer"
case author = "author"
case member = "member"
case admin = "admin"
case owner = "owner"
case observer
case author
case member
case moderator
case admin
case owner
public var id: Self { self }
public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .admin, .owner]
public var text: String {
switch self {
case .observer: return NSLocalizedString("observer", comment: "member role")
case .author: return NSLocalizedString("author", comment: "member role")
case .member: return NSLocalizedString("member", comment: "member role")
case .moderator: return NSLocalizedString("moderator", comment: "member role")
case .admin: return NSLocalizedString("admin", comment: "member role")
case .owner: return NSLocalizedString("owner", comment: "member role")
}
@ -2141,11 +2161,12 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod
private var comparisonValue: Int {
switch self {
case .observer: return 0
case .author: return 1
case .member: return 2
case .admin: return 3
case .owner: return 4
case .observer: 0
case .author: 1
case .member: 2
case .moderator: 3
case .admin: 4
case .owner: 5
}
}
@ -2551,6 +2572,17 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
default: return true
}
}
public var isReport: Bool {
switch content {
case let .sndMsgContent(msgContent), let .rcvMsgContent(msgContent):
switch msgContent {
case .report: true
default: false
}
default: false
}
}
public var canBeDeletedForSelf: Bool {
(content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete
@ -2636,6 +2668,34 @@ public struct ChatItem: Identifiable, Decodable, Hashable {
file: nil
)
}
public static func getReportSample(text: String, reason: ReportReason, item: ChatItem, sender: GroupMember? = nil) -> ChatItem {
let chatDir = if let sender = sender {
CIDirection.groupRcv(groupMember: sender)
} else {
CIDirection.groupSnd
}
return ChatItem(
chatDir: chatDir,
meta: CIMeta(
itemId: -2,
itemTs: .now,
itemText: "",
itemStatus: .rcvRead,
createdAt: .now,
updatedAt: .now,
itemDeleted: nil,
itemEdited: false,
itemLive: false,
deletable: false,
editable: false
),
content: .sndMsgContent(msgContent: .report(text: text, reason: reason)),
quotedItem: CIQuote.getSample(item.id, item.meta.createdAt, item.text, chatDir: item.chatDir),
file: nil
)
}
public static func deletedItemDummy() -> ChatItem {
ChatItem(
@ -3250,14 +3310,12 @@ public struct CIQuote: Decodable, ItemContent, Hashable {
public var sentAt: Date
public var content: MsgContent
public var formattedText: [FormattedText]?
public var text: String {
switch (content.text, content) {
case let ("", .voice(_, duration)): return durationText(duration)
default: return content.text
}
}
public func getSender(_ membership: GroupMember?) -> String? {
switch (chatDir) {
case .directSnd: return "you"
@ -3279,6 +3337,17 @@ public struct CIQuote: Decodable, ItemContent, Hashable {
}
return CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: mc)
}
public func memberToModerate(_ chatInfo: ChatInfo) -> GroupMember? {
switch (chatInfo, chatDir) {
case let (.group(groupInfo), .groupRcv(groupMember)):
let m = groupInfo.membership
return m.memberRole >= .admin && m.memberRole >= groupMember.memberRole
? groupMember
: nil
default: return nil
}
}
}
public struct CIReactionCount: Decodable, Hashable {
@ -3620,6 +3689,7 @@ public enum MsgContent: Equatable, Hashable {
case video(text: String, image: String, duration: Int)
case voice(text: String, duration: Int)
case file(String)
case report(text: String, reason: ReportReason)
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
case unknown(type: String, text: String)
@ -3631,6 +3701,7 @@ public enum MsgContent: Equatable, Hashable {
case let .video(text, _, _): return text
case let .voice(text, _): return text
case let .file(text): return text
case let .report(text, _): return text
case let .unknown(_, text): return text
}
}
@ -3690,6 +3761,7 @@ public enum MsgContent: Equatable, Hashable {
case preview
case image
case duration
case reason
}
public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool {
@ -3700,6 +3772,7 @@ public enum MsgContent: Equatable, Hashable {
case let (.video(lt, li, ld), .video(rt, ri, rd)): return lt == rt && li == ri && ld == rd
case let (.voice(lt, ld), .voice(rt, rd)): return lt == rt && ld == rd
case let (.file(lf), .file(rf)): return lf == rf
case let (.report(lt, lr), .report(rt, rr)): return lt == rt && lr == rr
case let (.unknown(lType, lt), .unknown(rType, rt)): return lType == rType && lt == rt
default: return false
}
@ -3735,6 +3808,10 @@ extension MsgContent: Decodable {
case "file":
let text = try container.decode(String.self, forKey: CodingKeys.text)
self = .file(text)
case "report":
let text = try container.decode(String.self, forKey: CodingKeys.text)
let reason = try container.decode(ReportReason.self, forKey: CodingKeys.reason)
self = .report(text: text, reason: reason)
default:
let text = try? container.decode(String.self, forKey: CodingKeys.text)
self = .unknown(type: type, text: text ?? "unknown message format")
@ -3772,6 +3849,10 @@ extension MsgContent: Encodable {
case let .file(text):
try container.encode("file", forKey: .type)
try container.encode(text, forKey: .text)
case let .report(text, reason):
try container.encode("report", forKey: .type)
try container.encode(text, forKey: .text)
try container.encode(reason, forKey: .reason)
// TODO use original JSON and type
case let .unknown(_, text):
try container.encode("text", forKey: .type)
@ -3851,6 +3932,57 @@ public enum FormatColor: String, Decodable, Hashable {
}
}
public enum ReportReason: Hashable {
case spam
case illegal
case community
case profile
case other
case unknown(type: String)
public static var supportedReasons: [ReportReason] = [.spam, .illegal, .community, .profile, .other]
public var text: String {
switch self {
case .spam: return NSLocalizedString("Spam", comment: "report reason")
case .illegal: 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")
case let .unknown(type): return type
}
}
}
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)
}
}
}
extension ReportReason: Decodable {
public init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let type = try container.decode(String.self)
switch type {
case "spam": self = .spam
case "illegal": self = .illegal
case "community": self = .community
case "profile": self = .profile
case "other": self = .other
default: self = .unknown(type: type)
}
}
}
// Struct to use with simplex API
public struct LinkPreview: Codable, Equatable, Hashable {
public init(uri: URL, title: String, description: String = "", image: String) {

View file

@ -1383,11 +1383,7 @@ data class Connection(
}
@Serializable
data class VersionRange(val minVersion: Int, val maxVersion: Int) {
fun isCompatibleRange(vRange: VersionRange): Boolean =
this.minVersion <= vRange.maxVersion && vRange.minVersion <= this.maxVersion
}
data class VersionRange(val minVersion: Int, val maxVersion: Int)
@Serializable
data class SecurityCode(val securityCode: String, val verifiedAt: Instant)

View file

@ -46,11 +46,8 @@ import java.util.Date
typealias ChatCtrl = Long
// currentChatVersion in core
const val CURRENT_CHAT_VERSION: Int = 2
// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core)
val CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion = 2, maxVersion = CURRENT_CHAT_VERSION)
val CREATE_MEMBER_CONTACT_VERSION = 2
enum class CallOnLockScreen {
DISABLE,