mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-15 01:35:38 +00:00
Merge branch 'stable'
This commit is contained in:
@@ -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 {
|
||||
@@ -460,6 +454,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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 you."),
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(member, width: buttonWidth)
|
||||
}
|
||||
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()
|
||||
@@ -781,12 +781,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()
|
||||
)
|
||||
@@ -797,23 +803,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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -51,6 +51,7 @@ public enum ChatCommand {
|
||||
case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData)
|
||||
case apiReorderChatTags(tagIds: [Int64])
|
||||
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])
|
||||
@@ -221,6 +222,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: ","))"
|
||||
@@ -390,6 +393,7 @@ public enum ChatCommand {
|
||||
case .apiUpdateChatTag: return "apiUpdateChatTag"
|
||||
case .apiReorderChatTags: return "apiReorderChatTags"
|
||||
case .apiCreateChatItems: return "apiCreateChatItems"
|
||||
case .apiReportMessage: return "apiReportMessage"
|
||||
case .apiUpdateChatItem: return "apiUpdateChatItem"
|
||||
case .apiDeleteChatItem: return "apiDeleteChatItem"
|
||||
case .apiConnectContactViaAddress: return "apiConnectContactViaAddress"
|
||||
@@ -1186,12 +1190,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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1695,7 +1701,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,
|
||||
@@ -1707,17 +1713,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 {
|
||||
@@ -1769,7 +1771,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,
|
||||
@@ -2008,6 +2010,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 ready: Bool { get { activeConn?.connStatus == .ready } }
|
||||
@@ -2110,7 +2113,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
|
||||
}
|
||||
@@ -2129,7 +2144,8 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
|
||||
memberProfile: LocalProfile.sampleData,
|
||||
memberContactId: 1,
|
||||
memberContactProfileId: 1,
|
||||
activeConn: Connection.sampleData
|
||||
activeConn: Connection.sampleData,
|
||||
memberChatVRange: VersionRange(2, 12)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2148,19 +2164,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")
|
||||
}
|
||||
@@ -2168,11 +2188,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2578,6 +2599,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
|
||||
@@ -2663,6 +2695,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(
|
||||
@@ -3277,14 +3337,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"
|
||||
@@ -3306,6 +3364,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 {
|
||||
@@ -3649,6 +3718,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)
|
||||
|
||||
@@ -3660,6 +3730,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
|
||||
}
|
||||
}
|
||||
@@ -3719,6 +3790,7 @@ public enum MsgContent: Equatable, Hashable {
|
||||
case preview
|
||||
case image
|
||||
case duration
|
||||
case reason
|
||||
}
|
||||
|
||||
public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool {
|
||||
@@ -3729,6 +3801,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
|
||||
}
|
||||
@@ -3764,6 +3837,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")
|
||||
@@ -3801,6 +3878,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)
|
||||
@@ -3880,6 +3961,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) {
|
||||
|
||||
+40
-6
@@ -1556,11 +1556,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)
|
||||
@@ -1824,7 +1820,7 @@ data class GroupMember (
|
||||
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
|
||||
if (!canBeRemoved(groupInfo)) null
|
||||
else groupInfo.membership.memberRole.let { userRole ->
|
||||
GroupMemberRole.values().filter { it <= userRole && it != GroupMemberRole.Author }
|
||||
GroupMemberRole.selectableRoles.filter { it <= userRole }
|
||||
}
|
||||
|
||||
fun canBlockForAll(groupInfo: GroupInfo): Boolean {
|
||||
@@ -1875,13 +1871,19 @@ enum class GroupMemberRole(val memberRole: String) {
|
||||
@SerialName("observer") Observer("observer"), // order matters in comparisons
|
||||
@SerialName("author") Author("author"),
|
||||
@SerialName("member") Member("member"),
|
||||
@SerialName("moderator") Moderator("moderator"),
|
||||
@SerialName("admin") Admin("admin"),
|
||||
@SerialName("owner") Owner("owner");
|
||||
|
||||
companion object {
|
||||
val selectableRoles: List<GroupMemberRole> = listOf(Observer, Member, Admin, Owner)
|
||||
}
|
||||
|
||||
val text: String get() = when (this) {
|
||||
Observer -> generalGetString(MR.strings.group_member_role_observer)
|
||||
Author -> generalGetString(MR.strings.group_member_role_author)
|
||||
Member -> generalGetString(MR.strings.group_member_role_member)
|
||||
Moderator -> generalGetString(MR.strings.group_member_role_moderator)
|
||||
Admin -> generalGetString(MR.strings.group_member_role_admin)
|
||||
Owner -> generalGetString(MR.strings.group_member_role_owner)
|
||||
}
|
||||
@@ -2302,6 +2304,12 @@ data class ChatItem (
|
||||
else -> true
|
||||
}
|
||||
|
||||
val isReport: Boolean get() = when (content) {
|
||||
is CIContent.SndMsgContent, is CIContent.RcvMsgContent ->
|
||||
content.msgContent is MsgContent.MCReport
|
||||
else -> false
|
||||
}
|
||||
|
||||
val canBeDeletedForSelf: Boolean
|
||||
get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete
|
||||
|
||||
@@ -3132,6 +3140,19 @@ class CIQuote (
|
||||
null -> null
|
||||
}
|
||||
|
||||
fun memberToModerate(chatInfo: ChatInfo): GroupMember? {
|
||||
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
|
||||
val m = chatInfo.groupInfo.membership
|
||||
if (m.memberRole >= GroupMemberRole.Moderator && m.memberRole >= chatDir.groupMember.memberRole) {
|
||||
chatDir.groupMember
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote =
|
||||
CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text))
|
||||
@@ -3777,6 +3798,19 @@ sealed class 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 text: String get() = when (this) {
|
||||
Spam -> generalGetString(MR.strings.report_reason_spam)
|
||||
Illegal -> 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)
|
||||
is Unknown -> type
|
||||
}
|
||||
}
|
||||
|
||||
object ReportReasonSerializer : KSerializer<ReportReason> {
|
||||
|
||||
+2
-5
@@ -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,
|
||||
@@ -3386,7 +3383,7 @@ sealed class CC {
|
||||
val msgs = json.encodeToString(composedMessages)
|
||||
"/_create *$noteFolderId json $msgs"
|
||||
}
|
||||
is ApiReportMessage -> "/_report #$groupId $chatItemId reason=$reportReason $reportText"
|
||||
is ApiReportMessage -> "/_report #$groupId $chatItemId reason=${json.encodeToString(reportReason).trim('"')} $reportText"
|
||||
is ApiUpdateChatItem -> "/_update item ${chatRef(type, id)} $itemId live=${onOff(live)} ${mc.cmdString}"
|
||||
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} ${itemIds.joinToString(",")} ${mode.deleteMode}"
|
||||
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId ${itemIds.joinToString(",")}"
|
||||
|
||||
+36
-36
@@ -309,41 +309,41 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
|
||||
}
|
||||
},
|
||||
deleteMessage = { itemId, mode ->
|
||||
withBGApi {
|
||||
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
|
||||
val toModerate = toDeleteItem?.memberToModerate(chatInfo)
|
||||
val groupInfo = toModerate?.first
|
||||
val groupMember = toModerate?.second
|
||||
val deletedChatItem: ChatItem?
|
||||
val toChatItem: ChatItem?
|
||||
val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
|
||||
chatModel.controller.apiDeleteMemberChatItems(
|
||||
chatRh,
|
||||
groupId = groupInfo.groupId,
|
||||
itemIds = listOf(itemId)
|
||||
)
|
||||
} else {
|
||||
chatModel.controller.apiDeleteChatItems(
|
||||
chatRh,
|
||||
type = chatInfo.chatType,
|
||||
id = chatInfo.apiId,
|
||||
itemIds = listOf(itemId),
|
||||
mode = mode
|
||||
)
|
||||
}
|
||||
val deleted = r?.firstOrNull()
|
||||
if (deleted != null) {
|
||||
deletedChatItem = deleted.deletedChatItem.chatItem
|
||||
toChatItem = deleted.toChatItem?.chatItem
|
||||
withChats {
|
||||
if (toChatItem != null) {
|
||||
upsertChatItem(chatRh, chatInfo, toChatItem)
|
||||
} else {
|
||||
removeChatItem(chatRh, chatInfo, deletedChatItem)
|
||||
}
|
||||
val toDeleteItem = chatModel.chatItems.value.firstOrNull { it.id == itemId }
|
||||
val toModerate = toDeleteItem?.memberToModerate(chatInfo)
|
||||
val groupInfo = toModerate?.first
|
||||
val groupMember = toModerate?.second
|
||||
val deletedChatItem: ChatItem?
|
||||
val toChatItem: ChatItem?
|
||||
val r = if (mode == CIDeleteMode.cidmBroadcast && groupInfo != null && groupMember != null) {
|
||||
chatModel.controller.apiDeleteMemberChatItems(
|
||||
chatRh,
|
||||
groupId = groupInfo.groupId,
|
||||
itemIds = listOf(itemId)
|
||||
)
|
||||
} else {
|
||||
chatModel.controller.apiDeleteChatItems(
|
||||
chatRh,
|
||||
type = chatInfo.chatType,
|
||||
id = chatInfo.apiId,
|
||||
itemIds = listOf(itemId),
|
||||
mode = mode
|
||||
)
|
||||
}
|
||||
val deleted = r?.firstOrNull()
|
||||
if (deleted != null) {
|
||||
deletedChatItem = deleted.deletedChatItem.chatItem
|
||||
toChatItem = deleted.toChatItem?.chatItem
|
||||
withChats {
|
||||
if (toChatItem != null) {
|
||||
upsertChatItem(chatRh, chatInfo, toChatItem)
|
||||
} else {
|
||||
removeChatItem(chatRh, chatInfo, deletedChatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deleted
|
||||
},
|
||||
deleteMessages = { itemIds -> deleteMessages(chatRh, chatInfo, itemIds, false, moderate = false) },
|
||||
receiveFile = { fileId ->
|
||||
@@ -613,7 +613,7 @@ fun ChatLayout(
|
||||
info: () -> Unit,
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
@@ -960,7 +960,7 @@ fun BoxScope.ChatItemsList(
|
||||
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
|
||||
showChatInfo: () -> Unit,
|
||||
loadMessages: suspend (ChatId, ChatPagination, ActiveChatState, visibleItemIndexesNonReversed: () -> IntRange) -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
@@ -2449,7 +2449,7 @@ fun PreviewChatLayout() {
|
||||
info = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadMessages = { _, _, _, _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessage = { _, _ -> null },
|
||||
deleteMessages = { _ -> },
|
||||
receiveFile = { _ -> },
|
||||
cancelFile = {},
|
||||
@@ -2522,7 +2522,7 @@ fun PreviewGroupChatLayout() {
|
||||
info = {},
|
||||
showMemberInfo = { _, _ -> },
|
||||
loadMessages = { _, _, _, _ -> },
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessage = { _, _ -> null },
|
||||
deleteMessages = {},
|
||||
receiveFile = { _ -> },
|
||||
cancelFile = {},
|
||||
|
||||
+69
-5
@@ -11,6 +11,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
@@ -51,6 +52,7 @@ sealed class ComposeContextItem {
|
||||
@Serializable class QuotedItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
@Serializable class EditingItem(val chatItem: ChatItem): ComposeContextItem()
|
||||
@Serializable class ForwardingItems(val chatItems: List<ChatItem>, val fromChatInfo: ChatInfo): ComposeContextItem()
|
||||
@Serializable class ReportedItem(val chatItem: ChatItem, val reason: ReportReason): ComposeContextItem()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@@ -89,13 +91,28 @@ data class ComposeState(
|
||||
is ComposeContextItem.ForwardingItems -> true
|
||||
else -> false
|
||||
}
|
||||
val reporting: Boolean
|
||||
get() = when (contextItem) {
|
||||
is ComposeContextItem.ReportedItem -> true
|
||||
else -> false
|
||||
}
|
||||
val submittingValidReport: Boolean
|
||||
get() = when (contextItem) {
|
||||
is ComposeContextItem.ReportedItem -> {
|
||||
when (contextItem.reason) {
|
||||
is ReportReason.Other -> message.isNotEmpty()
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
val sendEnabled: () -> Boolean
|
||||
get() = {
|
||||
val hasContent = when (preview) {
|
||||
is ComposePreview.MediaPreview -> true
|
||||
is ComposePreview.VoicePreview -> true
|
||||
is ComposePreview.FilePreview -> true
|
||||
else -> message.isNotEmpty() || forwarding || liveMessage != null
|
||||
else -> message.isNotEmpty() || forwarding || liveMessage != null || submittingValidReport
|
||||
}
|
||||
hasContent && !inProgress
|
||||
}
|
||||
@@ -119,7 +136,7 @@ data class ComposeState(
|
||||
|
||||
val attachmentDisabled: Boolean
|
||||
get() {
|
||||
if (editing || forwarding || liveMessage != null || inProgress) return true
|
||||
if (editing || forwarding || liveMessage != null || inProgress || reporting) return true
|
||||
return when (preview) {
|
||||
ComposePreview.NoPreview -> false
|
||||
is ComposePreview.CLinkPreview -> false
|
||||
@@ -136,6 +153,12 @@ data class ComposeState(
|
||||
is ComposePreview.FilePreview -> true
|
||||
}
|
||||
|
||||
val placeholder: String
|
||||
get() = when (contextItem) {
|
||||
is ComposeContextItem.ReportedItem -> contextItem.reason.text
|
||||
else -> generalGetString(MR.strings.compose_message_placeholder)
|
||||
}
|
||||
|
||||
val empty: Boolean
|
||||
get() = message.isEmpty() && preview is ComposePreview.NoPreview && contextItem is ComposeContextItem.NoContextItem
|
||||
|
||||
@@ -489,6 +512,19 @@ fun ComposeView(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendReport(reportReason: ReportReason, chatItemId: Long): List<ChatItem>? {
|
||||
val cItems = chatModel.controller.apiReportMessage(chat.remoteHostId, chat.chatInfo.apiId, chatItemId, reportReason, msgText)
|
||||
if (cItems != null) {
|
||||
withChats {
|
||||
cItems.forEach { chatItem ->
|
||||
addChatItem(chat.remoteHostId, chat.chatInfo, chatItem.chatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cItems?.map { it.chatItem }
|
||||
}
|
||||
|
||||
suspend fun sendMemberContactInvitation() {
|
||||
val mc = checkLinkPreview()
|
||||
val contact = chatModel.controller.apiSendMemberContactInvitation(chat.remoteHostId, chat.chatInfo.apiId, mc)
|
||||
@@ -554,6 +590,8 @@ fun ComposeView(
|
||||
} else if (liveMessage != null && liveMessage.sent) {
|
||||
val updatedMessage = updateMessage(liveMessage.chatItem, chat, live)
|
||||
sent = if (updatedMessage != null) listOf(updatedMessage) else null
|
||||
} else if (cs.contextItem is ComposeContextItem.ReportedItem) {
|
||||
sent = sendReport(cs.contextItem.reason, cs.contextItem.chatItem.id)
|
||||
} else {
|
||||
val msgs: ArrayList<MsgContent> = ArrayList()
|
||||
val files: ArrayList<CryptoFile> = ArrayList()
|
||||
@@ -835,14 +873,33 @@ fun ComposeView(
|
||||
|
||||
@Composable
|
||||
fun MsgNotAllowedView(reason: String, icon: Painter) {
|
||||
val color = MaterialTheme.appColors.receivedMessage
|
||||
Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
|
||||
val color = MaterialTheme.appColors.receivedQuote
|
||||
Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
|
||||
Text(reason, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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.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)
|
||||
is ReportReason.Unknown -> null // should never happen
|
||||
}
|
||||
|
||||
if (reportText != null) {
|
||||
val color = MaterialTheme.appColors.receivedQuote
|
||||
Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(reportText, fontStyle = FontStyle.Italic, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun contextItemView() {
|
||||
when (val contextItem = composeState.value.contextItem) {
|
||||
@@ -856,6 +913,9 @@ fun ComposeView(
|
||||
is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -893,6 +953,10 @@ fun ComposeView(
|
||||
if (nextSendGrpInv.value) {
|
||||
ComposeContextInvitingContactMemberView()
|
||||
}
|
||||
val ctx = composeState.value.contextItem
|
||||
if (ctx is ComposeContextItem.ReportedItem) {
|
||||
ReportReasonView(ctx.reason)
|
||||
}
|
||||
val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)
|
||||
val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files)
|
||||
val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
|
||||
@@ -1050,7 +1114,7 @@ fun ComposeView(
|
||||
sendButtonColor = sendButtonColor,
|
||||
timedMessageAllowed = timedMessageAllowed,
|
||||
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
|
||||
placeholder = stringResource(MR.strings.compose_message_placeholder),
|
||||
placeholder = composeState.value.placeholder,
|
||||
sendMessage = { ttl ->
|
||||
sendMessage(ttl)
|
||||
resetLinkPreview()
|
||||
|
||||
+4
-3
@@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -31,6 +32,7 @@ fun ContextItemView(
|
||||
contextIcon: Painter,
|
||||
showSender: Boolean = true,
|
||||
chatType: ChatType,
|
||||
contextIconColor: Color = MaterialTheme.colors.secondary,
|
||||
cancelContextItem: () -> Unit,
|
||||
) {
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
@@ -85,7 +87,6 @@ fun ContextItemView(
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(if (sent) sentColor else receivedColor),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -103,8 +104,8 @@ fun ContextItemView(
|
||||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
contentDescription = stringResource(MR.strings.icon_descr_context),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
)
|
||||
tint = contextIconColor,
|
||||
)
|
||||
|
||||
if (contextItems.count() == 1) {
|
||||
val contextItem = contextItems[0]
|
||||
|
||||
+4
-4
@@ -138,10 +138,10 @@ private fun recheckItems(chatInfo: ChatInfo,
|
||||
for (ci in chatItems) {
|
||||
if (selected.contains(ci.id)) {
|
||||
rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf
|
||||
rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote
|
||||
rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd
|
||||
rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null
|
||||
rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy
|
||||
rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport
|
||||
rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport
|
||||
rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport
|
||||
rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport
|
||||
rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -74,7 +74,7 @@ fun SendMsgView(
|
||||
}
|
||||
}
|
||||
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem)
|
||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||
val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() ||
|
||||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||
@@ -125,6 +125,9 @@ fun SendMsgView(
|
||||
}
|
||||
when {
|
||||
progressByTimeout -> ProgressIndicator()
|
||||
cs.contextItem is ComposeContextItem.ReportedItem -> {
|
||||
SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage)
|
||||
}
|
||||
showVoiceButton && sendMsgEnabled -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||
|
||||
+2
-2
@@ -209,8 +209,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val values = GroupMemberRole.values()
|
||||
.filter { it <= groupInfo.membership.memberRole && it != GroupMemberRole.Author }
|
||||
val values = GroupMemberRole.selectableRoles
|
||||
.filter { it <= groupInfo.membership.memberRole }
|
||||
.map { it to it.text }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.new_member_role),
|
||||
|
||||
+7
-9
@@ -757,13 +757,13 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem
|
||||
}
|
||||
}
|
||||
|
||||
fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
|
||||
fun blockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember, blockMember: () -> Unit = { withBGApi { blockMemberForAll(rhId, gInfo, mem, true) } }) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.block_for_all_question),
|
||||
text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName),
|
||||
confirmText = generalGetString(MR.strings.block_for_all),
|
||||
onConfirm = {
|
||||
blockMemberForAll(rhId, gInfo, mem, true)
|
||||
blockMember()
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
@@ -775,17 +775,15 @@ fun unblockForAllAlert(rhId: Long?, gInfo: GroupInfo, mem: GroupMember) {
|
||||
text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName),
|
||||
confirmText = generalGetString(MR.strings.unblock_for_all),
|
||||
onConfirm = {
|
||||
blockMemberForAll(rhId, gInfo, mem, false)
|
||||
withBGApi { blockMemberForAll(rhId, gInfo, mem, false) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) {
|
||||
withBGApi {
|
||||
val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked)
|
||||
withChats {
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
}
|
||||
suspend fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocked: Boolean) {
|
||||
val updatedMember = ChatController.apiBlockMemberForAll(rhId, gInfo.groupId, member.groupMemberId, blocked)
|
||||
withChats {
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+292
-20
@@ -1,5 +1,6 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.HoverInteraction
|
||||
@@ -20,14 +21,18 @@ import androidx.compose.ui.text.*
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.currentUser
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.chat.group.blockForAllAlert
|
||||
import chat.simplex.common.views.chat.group.blockMemberForAll
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.datetime.Clock
|
||||
@@ -72,7 +77,7 @@ fun ChatItemView(
|
||||
selectedChatItems: MutableState<Set<Long>?>,
|
||||
fillMaxWidth: Boolean = true,
|
||||
selectChatItem: () -> Unit,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
receiveFile: (Long) -> Unit,
|
||||
cancelFile: (Long) -> Unit,
|
||||
@@ -108,6 +113,12 @@ fun ChatItemView(
|
||||
val onLinkLongClick = { _: String -> showMenu.value = true }
|
||||
val live = remember { derivedStateOf { composeState.value.liveMessage != null } }.value
|
||||
|
||||
val deleteMessageAsync: (Long, CIDeleteMode) -> Unit = { id, mode ->
|
||||
withBGApi {
|
||||
deleteMessage(id, mode)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = if (fillMaxWidth) Modifier.fillMaxWidth() else Modifier,
|
||||
contentAlignment = alignment,
|
||||
@@ -282,7 +293,7 @@ fun ChatItemView(
|
||||
@Composable
|
||||
fun DeleteItemMenu() {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -295,7 +306,36 @@ fun ChatItemView(
|
||||
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
|
||||
when {
|
||||
// cItem.id check is a special case for live message chat item which has negative ID while not sent yet
|
||||
cItem.content.msgContent != null && cItem.id >= 0 -> {
|
||||
cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (cItem.chatDir is CIDirection.GroupSnd) {
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
} else {
|
||||
ArchiveReportItemAction(cItem, showMenu, deleteMessageAsync)
|
||||
val qItem = cItem.quotedItem
|
||||
if (qItem != null) {
|
||||
ModerateReportItemAction(rhId, cInfo, cItem, qItem, showMenu, deleteMessage)
|
||||
val rMember = qItem.memberToModerate(cInfo)
|
||||
if (rMember != null && !rMember.blockedByAdmin && rMember.canBlockForAll(cInfo.groupInfo)) {
|
||||
BlockMemberAction(
|
||||
rhId,
|
||||
chatInfo = cInfo,
|
||||
groupInfo = cInfo.groupInfo,
|
||||
cItem = cItem,
|
||||
reportedItem = qItem,
|
||||
member = rMember,
|
||||
showMenu = showMenu,
|
||||
deleteMessage = deleteMessage
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
|
||||
MsgReactionsMenu()
|
||||
@@ -381,11 +421,15 @@ fun ChatItemView(
|
||||
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
|
||||
}
|
||||
if (!(live && cItem.meta.isLive) && !preview) {
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
}
|
||||
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||
if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) {
|
||||
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage)
|
||||
if (cItem.chatDir !is CIDirection.GroupSnd) {
|
||||
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||
if (groupInfo != null) {
|
||||
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessageAsync)
|
||||
} else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole < GroupMemberRole.Moderator && !live) {
|
||||
ReportItemAction(cItem, composeState, showMenu)
|
||||
}
|
||||
}
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
@@ -403,7 +447,7 @@ fun ChatItemView(
|
||||
ExpandItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -413,7 +457,7 @@ fun ChatItemView(
|
||||
cItem.isDeletedContent -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -427,7 +471,7 @@ fun ChatItemView(
|
||||
} else {
|
||||
ExpandItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -436,7 +480,7 @@ fun ChatItemView(
|
||||
}
|
||||
else -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
if (selectedChatItems.value == null) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -453,7 +497,7 @@ fun ChatItemView(
|
||||
RevealItemAction(revealed, showMenu, reveal)
|
||||
}
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -487,7 +531,7 @@ fun ChatItemView(
|
||||
DeletedItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessageAsync, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -544,7 +588,7 @@ fun ChatItemView(
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessageAsync, deleteMessages)
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
@@ -778,7 +822,7 @@ fun ModerateItemAction(
|
||||
painterResource(MR.images.ic_flag),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
moderateMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
|
||||
moderateMessageAlertDialog(cItem.id, questionText, deleteMessage = deleteMessage)
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
@@ -847,6 +891,183 @@ private fun ShrinkItemAction(revealed: State<Boolean>, showMenu: MutableState<Bo
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportItemAction(
|
||||
cItem: ChatItem,
|
||||
composeState: MutableState<ComposeState>,
|
||||
showMenu: MutableState<Boolean>,
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.report_verb),
|
||||
painterResource(MR.images.ic_flag),
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(MR.strings.report_reason_alert_title),
|
||||
buttons = {
|
||||
ReportReason.supportedReasons.forEach { reason ->
|
||||
SectionItemView({
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(
|
||||
contextItem = ComposeContextItem.ReportedItem(cItem, reason),
|
||||
useLinkPreviews = false,
|
||||
preview = ComposePreview.NoPreview,
|
||||
)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(
|
||||
contextItem = ComposeContextItem.ReportedItem(cItem, reason),
|
||||
useLinkPreviews = false,
|
||||
preview = ComposePreview.NoPreview,
|
||||
)
|
||||
}
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ModerateReportItemAction(
|
||||
rhId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
cItem: ChatItem,
|
||||
reportedItem: CIQuote,
|
||||
showMenu: MutableState<Boolean>,
|
||||
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.moderate_verb),
|
||||
painterResource(MR.images.ic_flag),
|
||||
onClick = {
|
||||
withBGApi {
|
||||
val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id)
|
||||
if (reportedMessageId != null) {
|
||||
moderateMessageAlertDialog(
|
||||
reportedMessageId,
|
||||
questionText = moderateMessageQuestionText(chatInfo.featureEnabled(ChatFeature.FullDelete), 1),
|
||||
deleteMessage = { id, m ->
|
||||
withApi {
|
||||
val deleted = deleteMessage(id, m)
|
||||
if (deleted != null) {
|
||||
deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BlockMemberAction(
|
||||
rhId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
groupInfo: GroupInfo,
|
||||
cItem: ChatItem,
|
||||
reportedItem: CIQuote,
|
||||
member: GroupMember,
|
||||
showMenu: MutableState<Boolean>,
|
||||
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.block_member_button),
|
||||
painterResource(MR.images.ic_back_hand),
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.report_block_and_moderate_title),
|
||||
buttons = {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
withBGApi {
|
||||
val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id)
|
||||
if (reportedMessageId != null) {
|
||||
blockAndModerateAlertDialog(
|
||||
rhId,
|
||||
reportedMessageId = reportedMessageId,
|
||||
reportId = cItem.id,
|
||||
gInfo = groupInfo,
|
||||
mem = member,
|
||||
deleteMessage = deleteMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.report_block_and_moderate_block_and_moderate_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
withBGApi {
|
||||
val reportedMessageId = getLocalIdForReportedMessage(rhId, chatInfo, reportedItem, cItem.id)
|
||||
if (reportedMessageId != null) {
|
||||
blockForAllAlert(rhId, gInfo = groupInfo, mem = member, blockMember = {
|
||||
withBGApi {
|
||||
try {
|
||||
blockMemberForAll(
|
||||
rhId,
|
||||
gInfo = groupInfo,
|
||||
member = member,
|
||||
blocked = true
|
||||
)
|
||||
deleteMessage(reportedMessageId, CIDeleteMode.cidmInternalMark)
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, "BlockMemberAction block and moderate ${ex.message}")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.report_block_and_moderate_only_block_action), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState<Boolean>, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.archive_verb),
|
||||
painterResource(MR.images.ic_inventory_2),
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.report_archive_alert_title),
|
||||
text = generalGetString(MR.strings.report_archive_alert_desc),
|
||||
onConfirm = {
|
||||
deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark)
|
||||
},
|
||||
destructive = true,
|
||||
confirmText = generalGetString(MR.strings.archive_verb),
|
||||
)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) {
|
||||
val finalColor = if (color == Color.Unspecified) {
|
||||
@@ -1133,7 +1354,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
|
||||
if (chatItem.meta.deletable && !chatItem.localNote) {
|
||||
if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
@@ -1180,14 +1401,14 @@ fun moderateMessageQuestionText(fullDeleteAllowed: Boolean, count: Int): String
|
||||
}
|
||||
}
|
||||
|
||||
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
fun moderateMessageAlertDialog(chatItemId: Long, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.delete_member_message__question),
|
||||
text = questionText,
|
||||
confirmText = generalGetString(MR.strings.delete_verb),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
deleteMessage(chatItemId, CIDeleteMode.cidmBroadcast)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1202,8 +1423,59 @@ fun moderateMessagesAlertDialog(itemIds: List<Long>, questionText: String, delet
|
||||
)
|
||||
}
|
||||
|
||||
private fun blockAndModerateAlertDialog(
|
||||
rhId: Long?,
|
||||
reportedMessageId: Long,
|
||||
reportId: Long,
|
||||
gInfo: GroupInfo,
|
||||
mem: GroupMember,
|
||||
deleteMessage: suspend (Long, CIDeleteMode) -> ChatItemDeletion?
|
||||
) {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.report_block_and_moderate_confirmation_title),
|
||||
text = generalGetString(
|
||||
if (gInfo.fullGroupPreferences.fullDelete.on) MR.strings.report_block_and_moderate_confirmation_desc_full_delete else MR.strings.report_block_and_moderate_confirmation_desc_full_delete).format(mem.chatViewName),
|
||||
confirmText = generalGetString(MR.strings.report_block_and_moderate_confirmation_ok),
|
||||
onConfirm = {
|
||||
withBGApi {
|
||||
try {
|
||||
val deleted = deleteMessage(reportedMessageId, CIDeleteMode.cidmBroadcast)
|
||||
if (deleted != null) {
|
||||
blockMemberForAll(rhId, gInfo, mem, true)
|
||||
deleteMessage(reportId, CIDeleteMode.cidmInternalMark)
|
||||
}
|
||||
} catch (ex: Exception) {
|
||||
Log.e(TAG, "blockAndModerateAlertDialog block and moderate ${ex.message}")
|
||||
}
|
||||
}
|
||||
},
|
||||
destructive = true,
|
||||
)
|
||||
}
|
||||
|
||||
expect fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager)
|
||||
|
||||
private suspend fun getLocalIdForReportedMessage(
|
||||
rhId: Long?,
|
||||
chatInfo: ChatInfo,
|
||||
reportedItem: CIQuote,
|
||||
itemId: Long): Long? {
|
||||
if (reportedItem.itemId != null) {
|
||||
return reportedItem.itemId
|
||||
}
|
||||
val item = apiLoadSingleMessage(rhId, chatInfo.chatType, chatInfo.apiId, itemId)
|
||||
|
||||
if (item?.quotedItem?.itemId != null) {
|
||||
withChats {
|
||||
updateChatItem(chatInfo, item)
|
||||
}
|
||||
return item.quotedItem.itemId
|
||||
} else {
|
||||
showQuotedItemDoesNotExistAlert()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewChatItemView(
|
||||
@@ -1221,7 +1493,7 @@ fun PreviewChatItemView(
|
||||
range = remember { mutableStateOf(0..1) },
|
||||
selectedChatItems = remember { mutableStateOf(setOf()) },
|
||||
selectChatItem = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessage = { _, _ -> null },
|
||||
deleteMessages = { _ -> },
|
||||
receiveFile = { _ -> },
|
||||
cancelFile = {},
|
||||
@@ -1267,7 +1539,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
range = remember { mutableStateOf(0..1) },
|
||||
selectedChatItems = remember { mutableStateOf(setOf()) },
|
||||
selectChatItem = {},
|
||||
deleteMessage = { _, _ -> },
|
||||
deleteMessage = { _, _ -> null },
|
||||
deleteMessages = { _ -> },
|
||||
receiveFile = { _ -> },
|
||||
cancelFile = {},
|
||||
|
||||
+24
-4
@@ -88,7 +88,7 @@ fun FramedItemView(
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) {
|
||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) {
|
||||
val sentColor = MaterialTheme.appColors.sentQuote
|
||||
val receivedColor = MaterialTheme.appColors.receivedQuote
|
||||
Row(
|
||||
@@ -104,7 +104,7 @@ fun FramedItemView(
|
||||
icon,
|
||||
caption,
|
||||
Modifier.size(18.dp),
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
}
|
||||
Text(
|
||||
@@ -216,7 +216,18 @@ fun FramedItemView(
|
||||
.padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
|
||||
) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted != null) {
|
||||
if (ci.isReport) {
|
||||
if (ci.meta.itemDeleted == null) {
|
||||
FramedItemHeader(
|
||||
stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators),
|
||||
true,
|
||||
painterResource(MR.images.ic_flag),
|
||||
iconColor = Color.Red
|
||||
)
|
||||
} else {
|
||||
FramedItemHeader(stringResource(MR.strings.report_item_archived), true, painterResource(MR.images.ic_flag))
|
||||
}
|
||||
} else if (ci.meta.itemDeleted != null) {
|
||||
when (ci.meta.itemDeleted) {
|
||||
is CIDeleted.Moderated -> {
|
||||
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
|
||||
@@ -288,6 +299,14 @@ fun FramedItemView(
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCReport -> {
|
||||
val prefix = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
|
||||
append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
|
||||
}
|
||||
}
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
|
||||
}
|
||||
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
@@ -315,13 +334,14 @@ fun CIMarkdownText(
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
prefix: AnnotatedString? = null
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
MarkdownText(
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
|
||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-5
@@ -67,7 +67,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
|
||||
}
|
||||
val total = moderated + blocked + blockedByAdmin + deleted
|
||||
if (total <= 1)
|
||||
markedDeletedText(chatItem.meta)
|
||||
markedDeletedText(chatItem)
|
||||
else if (total == moderated)
|
||||
stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", "))
|
||||
else if (total == blockedByAdmin)
|
||||
@@ -77,7 +77,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
|
||||
else
|
||||
stringResource(MR.strings.marked_deleted_items_description).format(total)
|
||||
} else {
|
||||
markedDeletedText(chatItem.meta)
|
||||
markedDeletedText(chatItem)
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -91,10 +91,11 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
|
||||
)
|
||||
}
|
||||
|
||||
fun markedDeletedText(meta: CIMeta): String =
|
||||
when (meta.itemDeleted) {
|
||||
fun markedDeletedText(cItem: ChatItem): String =
|
||||
if (cItem.meta.itemDeleted != null && cItem.isReport) generalGetString(MR.strings.report_item_archived)
|
||||
else when (cItem.meta.itemDeleted) {
|
||||
is CIDeleted.Moderated ->
|
||||
String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName)
|
||||
String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName)
|
||||
is CIDeleted.Blocked ->
|
||||
generalGetString(MR.strings.blocked_item_description)
|
||||
is CIDeleted.BlockedByAdmin ->
|
||||
|
||||
+4
-1
@@ -71,7 +71,8 @@ fun MarkdownText (
|
||||
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean = false,
|
||||
showTimestamp: Boolean = true
|
||||
showTimestamp: Boolean = true,
|
||||
prefix: AnnotatedString? = null
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
@@ -123,6 +124,7 @@ fun MarkdownText (
|
||||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
if (text is String) append(text)
|
||||
else if (text is AnnotatedString) append(text)
|
||||
if (meta?.isLive == true) {
|
||||
@@ -136,6 +138,7 @@ fun MarkdownText (
|
||||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
for ((i, ft) in formattedText.withIndex()) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else if (toggleSecrets && ft.format is Format.Secret) {
|
||||
|
||||
+13
-1
@@ -21,6 +21,7 @@ import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
@@ -174,13 +175,23 @@ fun ChatPreviewView(
|
||||
val (text: CharSequence, inlineTextContent) = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) }
|
||||
ci.meta.itemDeleted == null -> ci.text to null
|
||||
else -> markedDeletedText(ci.meta) to null
|
||||
else -> markedDeletedText(ci) to null
|
||||
}
|
||||
val formattedText = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||
ci.meta.itemDeleted == null -> ci.formattedText
|
||||
else -> null
|
||||
}
|
||||
val prefix = when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCReport ->
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
|
||||
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
MarkdownText(
|
||||
text,
|
||||
formattedText,
|
||||
@@ -202,6 +213,7 @@ fun ChatPreviewView(
|
||||
),
|
||||
inlineContent = inlineTextContent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
prefix = prefix
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -37,6 +37,9 @@
|
||||
<string name="marked_deleted_items_description">%d messages marked deleted</string>
|
||||
<string name="moderated_item_description">moderated by %s</string>
|
||||
<string name="moderated_items_description">%1$d messages moderated by %2$s</string>
|
||||
<string name="report_item_visibility_submitter">Only you and moderators see it</string>
|
||||
<string name="report_item_visibility_moderators">Only sender and moderators see it</string>
|
||||
<string name="report_item_archived">archived report</string>
|
||||
<string name="blocked_item_description">blocked</string>
|
||||
<string name="blocked_by_admin_item_description">blocked by admin</string>
|
||||
<string name="blocked_items_description">%d messages blocked</string>
|
||||
@@ -94,6 +97,13 @@
|
||||
<string name="simplex_link_mode_browser">Via browser</string>
|
||||
<string name="simplex_link_mode_browser_warning">Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.</string>
|
||||
|
||||
<!-- Reports - ChatModel.kt -->
|
||||
<string name="report_reason_spam">Spam</string>
|
||||
<string name="report_reason_illegal">Inappropriate content</string>
|
||||
<string name="report_reason_community">Community guidelines violation</string>
|
||||
<string name="report_reason_profile">Inappropriate profile</string>
|
||||
<string name="report_reason_other">Another reason</string>
|
||||
|
||||
<!-- SimpleXAPI.kt -->
|
||||
<string name="error_saving_smp_servers">Error saving SMP servers</string>
|
||||
<string name="error_saving_xftp_servers">Error saving XFTP servers</string>
|
||||
@@ -295,6 +305,16 @@
|
||||
<string name="message_delivery_error_desc">Most likely this contact has deleted the connection with you.</string>
|
||||
<string name="message_deleted_or_not_received_error_title">No message</string>
|
||||
<string name="message_deleted_or_not_received_error_desc">This message was deleted or not received yet.</string>
|
||||
<string name="report_reason_alert_title">Report reason?</string>
|
||||
<string name="report_archive_alert_title">Archive report?</string>
|
||||
<string name="report_archive_alert_desc">The report will be archived for you.</string>
|
||||
<string name="report_block_and_moderate_title">Block and moderate?</string>
|
||||
<string name="report_block_and_moderate_block_and_moderate_action">Block and moderate</string>
|
||||
<string name="report_block_and_moderate_only_block_action">Only block</string>
|
||||
<string name="report_block_and_moderate_confirmation_title">Delete member message and block?</string>
|
||||
<string name="report_block_and_moderate_confirmation_desc_full_delete">The message will be deleted for all members.\nAll new messages from %1$s will be hidden!</string>
|
||||
<string name="report_block_and_moderate_confirmation_desc_mark_delete">The message will be marked as moderated for all members.\nAll new messages from %1$s will be hidden!</string>
|
||||
<string name="report_block_and_moderate_confirmation_ok">Delete and block</string>
|
||||
|
||||
<!-- CIStatus errors -->
|
||||
<string name="ci_status_other_error">Error: %1$s</string>
|
||||
@@ -320,6 +340,7 @@
|
||||
<string name="edit_verb">Edit</string>
|
||||
<string name="info_menu">Info</string>
|
||||
<string name="search_verb">Search</string>
|
||||
<string name="archive_verb">Archive</string>
|
||||
<string name="sent_message">Sent message</string>
|
||||
<string name="received_message">Received message</string>
|
||||
<string name="edit_history">History</string>
|
||||
@@ -337,6 +358,7 @@
|
||||
<string name="hide_verb">Hide</string>
|
||||
<string name="allow_verb">Allow</string>
|
||||
<string name="moderate_verb">Moderate</string>
|
||||
<string name="report_verb">Report</string>
|
||||
<string name="select_verb">Select</string>
|
||||
<string name="expand_verb">Expand</string>
|
||||
<string name="delete_message__question">Delete message?</string>
|
||||
@@ -463,6 +485,11 @@
|
||||
<string name="maximum_message_size_reached_text">Please reduce the message size and send again.</string>
|
||||
<string name="maximum_message_size_reached_non_text">Please reduce the message size or remove media and send again.</string>
|
||||
<string name="maximum_message_size_reached_forwarding">You can copy and reduce the message size to send it.</string>
|
||||
<string name="report_compose_reason_header_spam">Report spam: only group moderators will see it.</string>
|
||||
<string name="report_compose_reason_header_profile">Report member profile: only group moderators will see it.</string>
|
||||
<string name="report_compose_reason_header_community">Report violation: only group moderators will see it.</string>
|
||||
<string name="report_compose_reason_header_illegal">Report content: only group moderators will see it.</string>
|
||||
<string name="report_compose_reason_header_other">Report other: only group moderators will see it.</string>
|
||||
|
||||
<!-- Images - chat.simplex.app.views.chat.item.CIImageView.kt -->
|
||||
<string name="image_descr">Image</string>
|
||||
@@ -1557,6 +1584,7 @@
|
||||
<string name="group_member_role_observer">observer</string>
|
||||
<string name="group_member_role_author">author</string>
|
||||
<string name="group_member_role_member">member</string>
|
||||
<string name="group_member_role_moderator">moderator</string>
|
||||
<string name="group_member_role_admin">admin</string>
|
||||
<string name="group_member_role_owner">owner</string>
|
||||
|
||||
|
||||
@@ -3020,9 +3020,10 @@ getGroupHistoryItems db user@User {userId} GroupInfo {groupId} m count = do
|
||||
LEFT JOIN group_snd_item_statuses s ON s.chat_item_id = i.chat_item_id AND s.group_member_id = ?
|
||||
WHERE i.user_id = ? AND i.group_id = ?
|
||||
AND i.item_content_tag IN (?,?)
|
||||
AND i.msg_content_tag NOT IN (?)
|
||||
AND i.item_deleted = 0
|
||||
AND s.group_snd_item_status_id IS NULL
|
||||
ORDER BY i.item_ts DESC, i.chat_item_id DESC
|
||||
LIMIT ?
|
||||
|]
|
||||
(groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, count)
|
||||
(groupMemberId' m, userId, groupId, rcvMsgContentTag, sndMsgContentTag, MCReport_, count)
|
||||
|
||||
Reference in New Issue
Block a user