Merge branch 'master' into av/ios-infinity-scroll

This commit is contained in:
Avently committed 2025-01-13 23:28:22 -08:00
commit bf54e22bee
292 files changed
+7448 -3793

No files matched your search

+5
View File
@@ -89,7 +89,12 @@ jobs:
cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps:
- name: Skip unreliable ghc 8.10.7 build on stable branch
if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable'
run: exit 0
- name: Configure pagefile (Windows)
if: matrix.os == 'windows-latest'
uses: al-cheb/configure-pagefile-action@v1.3
+56 -8
View File
@@ -118,7 +118,7 @@ class ChatTagsModel: ObservableObject {
var newUnreadTags: [Int64:Int] = [:]
for chat in chats {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chat.chatInfo) {
if presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats) {
newPresetTags[tag] = (newPresetTags[tag] ?? 0) + 1
}
}
@@ -147,19 +147,23 @@ class ChatTagsModel: ObservableObject {
}
}
func addPresetChatTags(_ chatInfo: ChatInfo) {
func addPresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo) {
if presetTagMatchesChat(tag, chatInfo, chatStats) {
presetTags[tag] = (presetTags[tag] ?? 0) + 1
}
}
}
func removePresetChatTags(_ chatInfo: ChatInfo) {
func removePresetChatTags(_ chatInfo: ChatInfo, _ chatStats: ChatStats) {
for tag in PresetTag.allCases {
if presetTagMatchesChat(tag, chatInfo) {
if presetTagMatchesChat(tag, chatInfo, chatStats) {
if let count = presetTags[tag] {
presetTags[tag] = max(0, count - 1)
if count > 1 {
presetTags[tag] = count - 1
} else {
presetTags.removeValue(forKey: tag)
}
}
}
}
@@ -190,6 +194,11 @@ class ChatTagsModel: ObservableObject {
}
}
}
func changeGroupReportsTag(_ by: Int = 0) {
if by == 0 { return }
presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by
}
}
class NetworkModel: ObservableObject {
@@ -436,7 +445,7 @@ final class ChatModel: ObservableObject {
updateChatInfo(cInfo)
} else if addMissing {
addChat(Chat(chatInfo: cInfo, chatItems: []))
ChatTagsModel.shared.addPresetChatTags(cInfo)
ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats())
}
}
@@ -877,6 +886,27 @@ final class ChatModel: ObservableObject {
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
}
func increaseGroupReportsCounter(_ chatId: ChatId) {
changeGroupReportsCounter(chatId, 1)
}
func decreaseGroupReportsCounter(_ chatId: ChatId, by: Int = 1) {
changeGroupReportsCounter(chatId, -1)
}
private func changeGroupReportsCounter(_ chatId: ChatId, _ by: Int = 0) {
if by == 0 { return }
if let i = getChatIndex(chatId) {
let chat = chats[i]
let wasReportsCount = chat.chatStats.reportsCount
chat.chatStats.reportsCount = max(0, chat.chatStats.reportsCount + by)
let nowReportsCount = chat.chatStats.reportsCount
let by = wasReportsCount == 0 && nowReportsCount > 0 ? 1 : (wasReportsCount > 0 && nowReportsCount == 0) ? -1 : 0
ChatTagsModel.shared.changeGroupReportsTag(by)
}
}
// this function analyses "connected" events and assumes that each member will be there only once
func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) {
var count = 0
@@ -960,7 +990,8 @@ final class ChatModel: ObservableObject {
withAnimation {
if let i = getChatIndex(id) {
let removed = chats.remove(at: i)
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo)
ChatTagsModel.shared.removePresetChatTags(removed.chatInfo, removed.chatStats)
removeWallpaperFilesFromChat(removed)
}
}
}
@@ -999,6 +1030,23 @@ final class ChatModel: ObservableObject {
_ = upsertGroupMember(groupInfo, updatedMember)
}
}
func removeWallpaperFilesFromChat(_ chat: Chat) {
if case let .direct(contact) = chat.chatInfo {
removeWallpaperFilesFromTheme(contact.uiThemes)
} else if case let .group(groupInfo) = chat.chatInfo {
removeWallpaperFilesFromTheme(groupInfo.uiThemes)
}
}
func removeWallpaperFilesFromAllChats(_ user: User) {
// Currently, only removing everything from currently active user is supported. Inactive users are TODO
if user.userId == currentUser?.userId {
chats.forEach {
removeWallpaperFilesFromChat($0)
}
}
}
}
protocol ChatItemsChangesListener {
+61 -6
View File
@@ -15,12 +15,6 @@ import SimpleXChat
private var chatController: chat_ctrl?
// currentChatVersion in core
public let CURRENT_CHAT_VERSION: Int = 2
// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core)
public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION)
private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock")
enum TerminalItem: Identifiable {
@@ -459,6 +453,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(
@@ -845,6 +851,18 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
)
return (nil, alert)
case let .chatCmdError(_, .errorAgent(.SMP(_, .BLOCKED(info)))):
let alert = Alert(
title: Text("Connection blocked"),
message: Text("Connection is blocked by server operator:\n\(info.reason.text)"),
primaryButton: .default(Text("Ok")),
secondaryButton: .default(Text("How it works")) {
DispatchQueue.main.async {
UIApplication.shared.open(contentModerationPostLink)
}
}
)
return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(_, .QUOTA))):
let alert = mkAlert(
title: "Undelivered messages",
@@ -1985,6 +2003,9 @@ func processReceivedMsg(_ res: ChatResponse) async {
await MainActor.run {
if active(user) {
m.addChatItem(cInfo, cItem)
if cItem.isActiveReport {
m.increaseGroupReportsCounter(cInfo.id)
}
} else if cItem.isRcvNew && cInfo.ntfsEnabled {
m.increaseUnreadCounter(user: user)
}
@@ -2048,6 +2069,40 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
if !active(user) {
do {
let users = try listUsers()
await MainActor.run {
m.users = users
}
} catch {
logger.error("Error loading users: \(error)")
}
return
}
let im = ItemsModel.shared
let cInfo = ChatInfo.group(groupInfo: groupInfo)
await MainActor.run {
m.decreaseGroupReportsCounter(cInfo.id, by: chatItemIDs.count)
}
var notFound = chatItemIDs.count
for ci in im.reversedChatItems {
if chatItemIDs.contains(ci.id) {
let deleted = if case let .groupRcv(groupMember) = ci.chatDir, let member_, groupMember.groupMemberId != member_.groupMemberId {
CIDeleted.moderated(deletedTs: Date.now, byGroupMember: member_)
} else {
CIDeleted.deleted(deletedTs: Date.now)
}
await MainActor.run {
var newItem = ci
newItem.meta.itemDeleted = deleted
_ = m.upsertChatItem(cInfo, newItem)
}
notFound -= 1
if notFound == 0 { break }
}
}
case let .receivedGroupInvitation(user, groupInfo, _, _):
if active(user) {
await MainActor.run {
@@ -118,16 +118,10 @@ struct CIFileView: View {
}
case let .rcvError(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvError")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError)
case let .rcvWarning(rcvFileError):
logger.debug("CIFileView fileAction - in .rcvWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError, temporary: true)
case .sndStored:
logger.debug("CIFileView fileAction - in .sndStored")
if file.fileProtocol == .local, let fileSource = getLoadedFileSource(file) {
@@ -140,16 +134,10 @@ struct CIFileView: View {
}
case let .sndError(sndFileError):
logger.debug("CIFileView fileAction - in .sndError")
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError)
case let .sndWarning(sndFileError):
logger.debug("CIFileView fileAction - in .sndWarning")
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError, temporary: true)
default: break
}
}
@@ -268,6 +256,26 @@ func saveCryptoFile(_ fileSource: CryptoFile) {
}
}
func showFileErrorAlert(_ err: FileError, temporary: Bool = false) {
let title: String = if temporary {
NSLocalizedString("Temporary file error", comment: "file error alert title")
} else {
NSLocalizedString("File error", comment: "file error alert title")
}
if let btn = err.moreInfoButton {
showAlert(title, message: err.errorInfo) {
[
okAlertAction,
UIAlertAction(title: NSLocalizedString("How it works", comment: "alert button"), style: .default, handler: { _ in
UIApplication.shared.open(contentModerationPostLink)
})
]
}
} else {
showAlert(title, message: err.errorInfo)
}
}
struct CIFileView_Previews: PreviewProvider {
static var previews: some View {
let sentFile: ChatItem = ChatItem(
@@ -69,25 +69,13 @@ struct CIImageView: View {
case .rcvComplete: () // ?
case .rcvCancelled: () // TODO
case let .rcvError(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError)
case let .rcvWarning(rcvFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError, temporary: true)
case let .sndError(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError)
case let .sndWarning(sndFileError):
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError, temporary: true)
default: ()
}
}
@@ -355,18 +355,12 @@ struct CIVideoView: View {
case let .sndError(sndFileError):
fileIcon("xmark", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError)
}
case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError, temporary: true)
}
case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11)
@@ -382,18 +376,12 @@ struct CIVideoView: View {
case let .rcvError(rcvFileError):
fileIcon("xmark", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError)
}
case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError, temporary: true)
}
case .invalid: fileIcon("questionmark", 10, 13)
}
@@ -169,18 +169,12 @@ struct VoiceMessagePlayer: View {
case let .sndError(sndFileError):
fileStatusIcon("multiply", 14)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError)
}
case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(sndFileError.errorInfo)
))
showFileErrorAlert(sndFileError, temporary: true)
}
case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon()
@@ -191,18 +185,12 @@ struct VoiceMessagePlayer: View {
case let .rcvError(rcvFileError):
fileStatusIcon("multiply", 14)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("File error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError)
}
case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture {
AlertManager.shared.showAlert(Alert(
title: Text("Temporary file error"),
message: Text(rcvFileError.errorInfo)
))
showFileErrorAlert(rcvFileError, temporary: true)
}
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
}
@@ -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)
+80 -15
View File
@@ -963,6 +963,7 @@ struct ChatView: View {
@State private var allowMenu: Bool = true
@State private var markedRead = false
@State private var actionSheet: SomeActionSheet? = nil
var reveal: (Bool) -> Void
@@ -1072,6 +1073,7 @@ struct ChatView: View {
}
}
}
.actionSheet(item: $actionSheet) { $0.actionSheet }
}
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
@@ -1284,7 +1286,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)
}
@@ -1358,7 +1360,12 @@ 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 case let .group(gInfo) = chat.chatInfo, ci.isReport, ci.meta.itemDeleted == nil {
if ci.chatDir != .groupSnd, gInfo.membership.memberRole >= .moderator {
archiveReportButton(ci)
}
deleteButton(ci, label: "Delete report")
} 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
@@ -1408,8 +1415,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 == .member, !live, composeState.voiceMessageRecordingState == .noRecording {
// reportButton(ci)
// }
}
} else if ci.meta.itemDeleted != nil {
if revealed {
@@ -1683,7 +1694,7 @@ struct ChatView: View {
}
}
private func deleteButton(_ ci: ChatItem) -> Button<some View> {
private func deleteButton(_ ci: ChatItem, label: LocalizedStringKey = "Delete") -> Button<some View> {
Button(role: .destructive) {
if !revealed,
let currIndex = m.getChatItemIndex(ci),
@@ -1705,10 +1716,7 @@ struct ChatView: View {
deletingItem = ci
}
} label: {
Label(
NSLocalizedString("Delete", comment: "chat item action"),
systemImage: "trash"
)
Label(label, systemImage: "trash")
}
}
@@ -1727,10 +1735,10 @@ struct ChatView: View {
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."
),
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)
@@ -1744,6 +1752,24 @@ struct ChatView: View {
)
}
}
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("Archive report", systemImage: "archivebox")
}
}
private func revealButton(_ ci: ChatItem) -> Button<some View> {
Button {
@@ -1783,7 +1809,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?"
@@ -1844,11 +1901,15 @@ struct ChatView: View {
} else {
m.removeChatItem(chat.chatInfo, itemDeletion.deletedChatItem.chatItem)
}
let deletedItem = itemDeletion.deletedChatItem.chatItem
if deletedItem.isActiveReport {
m.decreaseGroupReportsCounter(chat.chatInfo.id)
}
}
}
}
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
logger.error("ChatView.deleteMessage error: \(error)")
}
}
}
@@ -1921,6 +1982,10 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
} else {
ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
let deletedItem = di.deletedChatItem.chatItem
if deletedItem.isActiveReport {
ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id)
}
}
}
await onSuccess()
@@ -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)
@@ -691,6 +724,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 {
@@ -720,6 +774,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()
}
}
@@ -751,6 +814,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 {
@@ -877,6 +942,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)
}
@@ -896,7 +963,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()
@@ -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 {
@@ -8,7 +8,6 @@
import SwiftUI
import SimpleXChat
import ElegantEmojiPicker
typealias DynamicSizes = (
rowHeight: CGFloat,
@@ -343,9 +342,9 @@ struct ChatListNavLink: View {
AnyView(
NavigationView {
if chatTagsModel.userTags.isEmpty {
ChatListTagEditor(chat: chat)
TagListEditor(chat: chat)
} else {
ChatListTag(chat: chat)
TagListView(chat: chat)
}
}
)
@@ -560,405 +559,6 @@ struct ChatListNavLink: View {
}
}
struct TagEditorNavParams {
let chat: Chat?
let chatListTag: ChatTagData?
let tagId: Int64?
}
struct ChatListTag: View {
var chat: Chat? = nil
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var m: ChatModel
@State private var editMode = EditMode.inactive
@State private var tagEditorNavParams: TagEditorNavParams? = nil
var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
var body: some View {
List {
Section {
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let text = tag.chatTagText
let emoji = tag.chatTagEmoji
let tagId = tag.chatTagId
let selected = chatTagsIds.contains(tagId)
HStack {
if let emoji {
Text(emoji)
} else {
Image(systemName: "tag")
}
Text(text)
.padding(.leading, 12)
Spacer()
if chat != nil {
radioButton(selected: selected)
}
}
.contentShape(Rectangle())
.onTapGesture {
if let c = chat {
setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() }
} else {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default
),
UIAlertAction(
title: NSLocalizedString("Delete", comment: "alert action"),
style: .destructive,
handler: { _ in
deleteTag(tagId)
}
)
]}
)
} label: {
Label("Delete", systemImage: "trash.fill")
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(theme.colors.primary)
}
.background(
// isActive required to navigate to edit view from any possible tag edited in swipe action
NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) {
if let params = tagEditorNavParams {
ChatListTagEditor(
chat: params.chat,
tagId: params.tagId,
emoji: params.chatListTag?.emoji,
name: params.chatListTag?.text ?? ""
)
}
} label: {
EmptyView()
}
.opacity(0)
)
}
.onMove(perform: moveItem)
NavigationLink {
ChatListTagEditor(chat: chat)
} label: {
Label("Create list", systemImage: "plus")
}
} header: {
if chat == nil {
editTagsButton()
.textCase(nil)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.modifier(ThemedBackground(grouped: true))
.environment(\.editMode, $editMode)
}
private func editTagsButton() -> some View {
if editMode.isEditing {
Button("Done") {
editMode = .inactive
dismiss()
}
} else {
Button("Edit") {
editMode = .active
}
}
}
@ViewBuilder private func radioButton(selected: Bool) -> some View {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.imageScale(.large)
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
}
private func moveItem(from source: IndexSet, to destination: Int) {
Task {
do {
var tags = chatTagsModel.userTags
tags.move(fromOffsets: source, toOffset: destination)
try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId })
await MainActor.run {
chatTagsModel.userTags = tags
}
} catch let error {
showAlert(
NSLocalizedString("Error reordering lists", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func deleteTag(_ tagId: Int64) {
Task {
try await apiDeleteChatTag(tagId: tagId)
await MainActor.run {
chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId }
if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId {
chatTagsModel.activeFilter = nil
}
m.chats.forEach { c in
if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) {
contact.chatTags = contact.chatTags.filter({ $0 != tagId })
m.updateContact(contact)
} else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) {
group.chatTags = group.chatTags.filter({ $0 != tagId })
m.updateGroup(group)
}
}
}
}
}
}
private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) {
Task {
do {
let tagIds: [Int64] = if let t = tagId { [t] } else {[]}
let (userTags, chatTags) = try await apiSetChatTags(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tagIds: tagIds
)
await MainActor.run {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
tm.userTags = userTags
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
tm.decTagsReadCount(tags)
}
if var contact = chat.chatInfo.contact {
contact.chatTags = chatTags
m.updateContact(contact)
} else if var group = chat.chatInfo.groupInfo {
group.chatTags = chatTags
m.updateGroup(group)
}
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false)
closeSheet()
}
} catch let error {
showAlert(
NSLocalizedString("Error saving chat list", comment: "alert title"),
message: responseError(error)
)
}
}
}
struct EmojiPickerView: UIViewControllerRepresentable {
@Binding var selectedEmoji: String?
@Binding var showingPicker: Bool
@Environment(\.presentationMode) var presentationMode
class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate {
var parent: EmojiPickerView
init(parent: EmojiPickerView) {
self.parent = parent
}
func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) {
parent.selectedEmoji = emoji?.emoji
parent.showingPicker = false
picker.dismiss(animated: true)
}
// Called when the picker is dismissed manually (without selection)
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
parent.showingPicker = false
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIViewController {
let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false)
let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config)
picker.presentationController?.delegate = context.coordinator
let viewController = UIViewController()
DispatchQueue.main.async {
if let topVC = getTopViewController() {
topVC.present(picker, animated: true)
}
}
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// No need to update the controller after creation
}
}
struct ChatListTagEditor: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var theme: AppTheme
var chat: Chat? = nil
var tagId: Int64? = nil
var emoji: String?
var name: String = ""
@State private var newEmoji: String?
@State private var newName: String = ""
@State private var isPickerPresented = false
@State private var saving: Bool?
var body: some View {
VStack {
List {
let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in
tag.chatTagId != tagId &&
((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName)
}
Section {
HStack {
Button {
isPickerPresented = true
} label: {
if let newEmoji {
Text(newEmoji)
} else {
Image(systemName: "face.smiling")
.foregroundColor(.secondary)
}
}
TextField("List name...", text: $newName)
}
Button {
saving = true
if let tId = tagId {
updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName))
} else {
createChatTag()
}
} label: {
Text(
chat != nil
? "Add to list"
: tagId == nil
? "Create list"
: "Save list"
)
}
.disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName)
} footer: {
if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering
HStack {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
Text("List name and emoji should be different for all lists.")
.foregroundColor(theme.colors.secondary)
}
}
}
}
if isPickerPresented {
EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented)
}
}
.modifier(ThemedBackground(grouped: true))
.onAppear {
newEmoji = emoji
newName = name
}
}
var trimmedName: String {
newName.trimmingCharacters(in: .whitespaces)
}
private func createChatTag() {
Task {
do {
let text = trimmedName
let userTags = try await apiCreateChatTag(
tag: ChatTagData(emoji: newEmoji , text: text)
)
await MainActor.run {
saving = false
chatTagsModel.userTags = userTags
}
if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) {
setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() }
} else {
await MainActor.run { dismiss() }
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
Task {
do {
try await apiUpdateChatTag(tagId: tagId, tag: chatTagData)
await MainActor.run {
saving = false
for i in 0..<chatTagsModel.userTags.count {
if chatTagsModel.userTags[i].chatTagId == tagId {
chatTagsModel.userTags[i] = ChatTag(
chatTagId: tagId,
chatTagText: chatTagData.text,
chatTagEmoji: chatTagData.emoji
)
}
}
dismiss()
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
@@ -32,12 +32,18 @@ enum UserPickerSheet: Identifiable {
}
enum PresetTag: Int, Identifiable, CaseIterable, Equatable {
case favorites = 0
case contacts = 1
case groups = 2
case business = 3
case groupReports = 0
case favorites = 1
case contacts = 2
case groups = 3
case business = 4
case notes = 5
var id: Int { rawValue }
var сollapse: Bool {
self != .groupReports
}
}
enum ActiveFilter: Identifiable, Equatable {
@@ -472,7 +478,7 @@ struct ChatListView: View {
func filtered(_ chat: Chat) -> Bool {
switch chatTagsModel.activeFilter {
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo)
case let .presetTag(tag): presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)
case let .userTag(tag): chat.chatInfo.chatTags?.contains(tag.chatTagId) == true
case .unread: chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
case .none: true
@@ -563,7 +569,7 @@ struct ChatListSearchBar: View {
var body: some View {
VStack(spacing: 12) {
ScrollView([.horizontal], showsIndicators: false) { ChatTagsView(parentSheet: $parentSheet) }
ScrollView([.horizontal], showsIndicators: false) { TagsView(parentSheet: $parentSheet, searchText: $searchText) }
HStack(spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "magnifyingglass")
@@ -621,6 +627,9 @@ struct ChatListSearchBar: View {
}
}
}
.onChange(of: chatTagsModel.activeFilter) { _ in
searchText = ""
}
.alert(item: $alert) { a in
planAndConnectAlert(a, dismiss: true, cleanup: { searchText = "" })
}
@@ -662,11 +671,12 @@ struct ChatListSearchBar: View {
}
}
struct ChatTagsView: View {
struct TagsView: View {
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var parentSheet: SomeSheet<AnyView>?
@Binding var searchText: String
var body: some View {
HStack {
@@ -680,6 +690,11 @@ struct ChatTagsView: View {
expandedPresetTagsFiltersView()
} else {
collapsedTagsFilterView()
ForEach(PresetTag.allCases, id: \.id) { (tag: PresetTag) in
if !tag.сollapse && (chatTagsModel.presetTags[tag] ?? 0) > 0 {
expandedTagFilterView(tag)
}
}
}
}
let selectedTag: ChatTag? = if case let .userTag(tag) = chatTagsModel.activeFilter {
@@ -717,7 +732,7 @@ struct ChatTagsView: View {
content: {
AnyView(
NavigationView {
ChatListTag(chat: nil)
TagListView(chat: nil)
.modifier(ThemedBackground(grouped: true))
}
)
@@ -734,7 +749,7 @@ struct ChatTagsView: View {
content: {
AnyView(
NavigationView {
ChatListTagEditor()
TagListEditor()
}
)
},
@@ -752,30 +767,34 @@ struct ChatTagsView: View {
}
.foregroundColor(.secondary)
}
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
@ViewBuilder private func expandedTagFilterView(_ tag: PresetTag) -> some View {
let selectedPresetTag: PresetTag? = if case let .presetTag(tag) = chatTagsModel.activeFilter {
tag
} else {
nil
}
let active = tag == selectedPresetTag
let (icon, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack(spacing: 4) {
Image(systemName: icon)
.foregroundColor(color)
ZStack {
Text(text).fontWeight(.semibold).foregroundColor(.clear)
Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .presetTag(tag))
}
}
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View {
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
let active = tag == selectedPresetTag
let (icon, text) = presetTagLabel(tag: tag, active: active)
let color: Color = active ? .accentColor : .secondary
HStack(spacing: 4) {
Image(systemName: icon)
.foregroundColor(color)
ZStack {
Text(text).fontWeight(.semibold).foregroundColor(.clear)
Text(text).fontWeight(active ? .semibold : .regular).foregroundColor(color)
}
}
.onTapGesture {
setActiveFilter(filter: .presetTag(tag))
}
expandedTagFilterView(tag)
}
}
}
@@ -787,9 +806,10 @@ struct ChatTagsView: View {
nil
}
Menu {
if selectedPresetTag != nil {
if chatTagsModel.activeFilter != nil || !searchText.isEmpty {
Button {
chatTagsModel.activeFilter = nil
searchText = ""
} label: {
HStack {
Image(systemName: "list.bullet")
@@ -798,7 +818,7 @@ struct ChatTagsView: View {
}
}
ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
if (chatTagsModel.presetTags[tag] ?? 0) > 0 && tag.сollapse {
Button {
setActiveFilter(filter: .presetTag(tag))
} label: {
@@ -811,7 +831,7 @@ struct ChatTagsView: View {
}
}
} label: {
if let tag = selectedPresetTag {
if let tag = selectedPresetTag, tag.сollapse {
let (systemName, _) = presetTagLabel(tag: tag, active: true)
Image(systemName: systemName)
.foregroundColor(.accentColor)
@@ -825,13 +845,15 @@ struct ChatTagsView: View {
private func presetTagLabel(tag: PresetTag, active: Bool) -> (String, LocalizedStringKey) {
switch tag {
case .groupReports: (active ? "flag.fill" : "flag", "Reports")
case .favorites: (active ? "star.fill" : "star", "Favorites")
case .contacts: (active ? "person.fill" : "person", "Contacts")
case .groups: (active ? "person.2.fill" : "person.2", "Groups")
case .business: (active ? "briefcase.fill" : "briefcase", "Businesses")
case .notes: (active ? "folder.fill" : "folder", "Notes")
}
}
private func setActiveFilter(filter: ActiveFilter) {
if filter != chatTagsModel.activeFilter {
chatTagsModel.activeFilter = filter
@@ -852,8 +874,10 @@ func chatStoppedIcon() -> some View {
}
}
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool {
func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo, _ chatStats: ChatStats) -> Bool {
switch tag {
case .groupReports:
chatStats.reportsCount > 0
case .favorites:
chatInfo.chatSettings?.favorite == true
case .contacts:
@@ -871,6 +895,11 @@ func presetTagMatchesChat(_ tag: PresetTag, _ chatInfo: ChatInfo) -> Bool {
}
case .business:
chatInfo.groupInfo?.businessChat?.chatType == .business
case .notes:
switch chatInfo {
case .local: true
default: false
}
}
}
@@ -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(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red)
default: return Text("")
}
}
}
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
@@ -302,6 +313,7 @@ struct ChatPreviewView: View {
}
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let linkClicksEnabled = privacyChatListOpenLinksDefault.get() != PrivacyChatListOpenLinksMode.no
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
@@ -323,7 +335,17 @@ struct ChatPreviewView: View {
.cornerRadius(8)
}
.onTapGesture {
UIApplication.shared.open(preview.uri)
switch privacyChatListOpenLinksDefault.get() {
case .yes: UIApplication.shared.open(preview.uri)
case .no: ItemsModel.shared.loadOpenChat(chat.id)
case .ask: AlertManager.shared.showAlert(
Alert(title: Text("Open web link?"),
message: Text(preview.uri.absoluteString),
primaryButton: .default(Text("Open chat"), action: { ItemsModel.shared.loadOpenChat(chat.id) }),
secondaryButton: .default(Text("Open link"), action: { UIApplication.shared.open(preview.uri) })
)
)
}
}
}
case let .image(_, image):
@@ -388,6 +410,8 @@ struct ChatPreviewView: View {
case .group:
if progressByTimeout {
ProgressView()
} else if chat.chatStats.reportsCount > 0 {
groupReportsIcon(size: size * 0.8)
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
@@ -433,6 +457,14 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View {
Image(systemName: "flag")
.resizable()
.scaledToFit()
.frame(width: size, height: size)
.foregroundColor(.red)
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
@@ -0,0 +1,408 @@
//
// TagListView.swift
// SimpleX (iOS)
//
// Created by Diogo Cunha on 31/12/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
import ElegantEmojiPicker
struct TagEditorNavParams {
let chat: Chat?
let chatListTag: ChatTagData?
let tagId: Int64?
}
struct TagListView: View {
var chat: Chat? = nil
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var theme: AppTheme
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var m: ChatModel
@State private var editMode = EditMode.inactive
@State private var tagEditorNavParams: TagEditorNavParams? = nil
var chatTagsIds: [Int64] { chat?.chatInfo.contact?.chatTags ?? chat?.chatInfo.groupInfo?.chatTags ?? [] }
var body: some View {
List {
Section {
ForEach(chatTagsModel.userTags, id: \.id) { tag in
let text = tag.chatTagText
let emoji = tag.chatTagEmoji
let tagId = tag.chatTagId
let selected = chatTagsIds.contains(tagId)
HStack {
if let emoji {
Text(emoji)
} else {
Image(systemName: "tag")
}
Text(text)
.padding(.leading, 12)
Spacer()
if chat != nil {
radioButton(selected: selected)
}
}
.contentShape(Rectangle())
.onTapGesture {
if let c = chat {
setChatTag(tagId: selected ? nil : tagId, chat: c) { dismiss() }
} else {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showAlert(
NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"),
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default
),
UIAlertAction(
title: NSLocalizedString("Delete", comment: "alert action"),
style: .destructive,
handler: { _ in
deleteTag(tagId)
}
)
]}
)
} label: {
Label("Delete", systemImage: "trash.fill")
}
.tint(.red)
}
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
tagEditorNavParams = TagEditorNavParams(chat: nil, chatListTag: ChatTagData(emoji: emoji, text: text), tagId: tagId)
} label: {
Label("Edit", systemImage: "pencil")
}
.tint(theme.colors.primary)
}
.background(
// isActive required to navigate to edit view from any possible tag edited in swipe action
NavigationLink(isActive: Binding(get: { tagEditorNavParams != nil }, set: { _ in tagEditorNavParams = nil })) {
if let params = tagEditorNavParams {
TagListEditor(
chat: params.chat,
tagId: params.tagId,
emoji: params.chatListTag?.emoji,
name: params.chatListTag?.text ?? ""
)
}
} label: {
EmptyView()
}
.opacity(0)
)
}
.onMove(perform: moveItem)
NavigationLink {
TagListEditor(chat: chat)
} label: {
Label("Create list", systemImage: "plus")
}
} header: {
if chat == nil {
editTagsButton()
.textCase(nil)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
.modifier(ThemedBackground(grouped: true))
.environment(\.editMode, $editMode)
}
private func editTagsButton() -> some View {
if editMode.isEditing {
Button("Done") {
editMode = .inactive
dismiss()
}
} else {
Button("Edit") {
editMode = .active
}
}
}
@ViewBuilder private func radioButton(selected: Bool) -> some View {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.imageScale(.large)
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))
}
private func moveItem(from source: IndexSet, to destination: Int) {
Task {
do {
var tags = chatTagsModel.userTags
tags.move(fromOffsets: source, toOffset: destination)
try await apiReorderChatTags(tagIds: tags.map { $0.chatTagId })
await MainActor.run {
chatTagsModel.userTags = tags
}
} catch let error {
showAlert(
NSLocalizedString("Error reordering lists", comment: "alert title"),
message: responseError(error)
)
}
}
}
private func deleteTag(_ tagId: Int64) {
Task {
try await apiDeleteChatTag(tagId: tagId)
await MainActor.run {
chatTagsModel.userTags = chatTagsModel.userTags.filter { $0.chatTagId != tagId }
if case let .userTag(tag) = chatTagsModel.activeFilter, tagId == tag.chatTagId {
chatTagsModel.activeFilter = nil
}
m.chats.forEach { c in
if var contact = c.chatInfo.contact, contact.chatTags.contains(tagId) {
contact.chatTags = contact.chatTags.filter({ $0 != tagId })
m.updateContact(contact)
} else if var group = c.chatInfo.groupInfo, group.chatTags.contains(tagId) {
group.chatTags = group.chatTags.filter({ $0 != tagId })
m.updateGroup(group)
}
}
}
}
}
}
private func setChatTag(tagId: Int64?, chat: Chat, closeSheet: @escaping () -> Void) {
Task {
do {
let tagIds: [Int64] = if let t = tagId { [t] } else {[]}
let (userTags, chatTags) = try await apiSetChatTags(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
tagIds: tagIds
)
await MainActor.run {
let m = ChatModel.shared
let tm = ChatTagsModel.shared
tm.userTags = userTags
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
tm.decTagsReadCount(tags)
}
if var contact = chat.chatInfo.contact {
contact.chatTags = chatTags
m.updateContact(contact)
} else if var group = chat.chatInfo.groupInfo {
group.chatTags = chatTags
m.updateGroup(group)
}
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: false)
closeSheet()
}
} catch let error {
showAlert(
NSLocalizedString("Error saving chat list", comment: "alert title"),
message: responseError(error)
)
}
}
}
struct EmojiPickerView: UIViewControllerRepresentable {
@Binding var selectedEmoji: String?
@Binding var showingPicker: Bool
@Environment(\.presentationMode) var presentationMode
class Coordinator: NSObject, ElegantEmojiPickerDelegate, UIAdaptivePresentationControllerDelegate {
var parent: EmojiPickerView
init(parent: EmojiPickerView) {
self.parent = parent
}
func emojiPicker(_ picker: ElegantEmojiPicker, didSelectEmoji emoji: Emoji?) {
parent.selectedEmoji = emoji?.emoji
parent.showingPicker = false
picker.dismiss(animated: true)
}
// Called when the picker is dismissed manually (without selection)
func presentationControllerWillDismiss(_ presentationController: UIPresentationController) {
parent.showingPicker = false
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(parent: self)
}
func makeUIViewController(context: Context) -> UIViewController {
let config = ElegantConfiguration(showRandom: false, showReset: true, showClose: false)
let picker = ElegantEmojiPicker(delegate: context.coordinator, configuration: config)
picker.presentationController?.delegate = context.coordinator
let viewController = UIViewController()
DispatchQueue.main.async {
if let topVC = getTopViewController() {
topVC.present(picker, animated: true)
}
}
return viewController
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
// No need to update the controller after creation
}
}
struct TagListEditor: View {
@Environment(\.dismiss) var dismiss: DismissAction
@EnvironmentObject var chatTagsModel: ChatTagsModel
@EnvironmentObject var theme: AppTheme
var chat: Chat? = nil
var tagId: Int64? = nil
var emoji: String?
var name: String = ""
@State private var newEmoji: String?
@State private var newName: String = ""
@State private var isPickerPresented = false
@State private var saving: Bool?
var body: some View {
VStack {
List {
let isDuplicateEmojiOrName = chatTagsModel.userTags.contains { tag in
tag.chatTagId != tagId &&
((newEmoji != nil && tag.chatTagEmoji == newEmoji) || tag.chatTagText == trimmedName)
}
Section {
HStack {
Button {
isPickerPresented = true
} label: {
if let newEmoji {
Text(newEmoji)
} else {
Image(systemName: "face.smiling")
.foregroundColor(.secondary)
}
}
TextField("List name...", text: $newName)
}
Button {
saving = true
if let tId = tagId {
updateChatTag(tagId: tId, chatTagData: ChatTagData(emoji: newEmoji, text: trimmedName))
} else {
createChatTag()
}
} label: {
Text(
chat != nil
? "Add to list"
: "Save list"
)
}
.disabled(saving != nil || (trimmedName == name && newEmoji == emoji) || trimmedName.isEmpty || isDuplicateEmojiOrName)
} footer: {
if isDuplicateEmojiOrName && saving != false { // if not saved already, to prevent flickering
HStack {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
Text("List name and emoji should be different for all lists.")
.foregroundColor(theme.colors.secondary)
}
}
}
}
if isPickerPresented {
EmojiPickerView(selectedEmoji: $newEmoji, showingPicker: $isPickerPresented)
}
}
.modifier(ThemedBackground(grouped: true))
.onAppear {
newEmoji = emoji
newName = name
}
}
var trimmedName: String {
newName.trimmingCharacters(in: .whitespaces)
}
private func createChatTag() {
Task {
do {
let text = trimmedName
let userTags = try await apiCreateChatTag(
tag: ChatTagData(emoji: newEmoji , text: text)
)
await MainActor.run {
saving = false
chatTagsModel.userTags = userTags
}
if let chat, let tag = userTags.first(where: { $0.chatTagText == text && $0.chatTagEmoji == newEmoji}) {
setChatTag(tagId: tag.chatTagId, chat: chat) { dismiss() }
} else {
await MainActor.run { dismiss() }
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
private func updateChatTag(tagId: Int64, chatTagData: ChatTagData) {
Task {
do {
try await apiUpdateChatTag(tagId: tagId, tag: chatTagData)
await MainActor.run {
saving = false
for i in 0..<chatTagsModel.userTags.count {
if chatTagsModel.userTags[i].chatTagId == tagId {
chatTagsModel.userTags[i] = ChatTag(
chatTagId: tagId,
chatTagText: chatTagData.text,
chatTagEmoji: chatTagData.emoji
)
}
}
dismiss()
}
} catch let error {
await MainActor.run {
saving = nil
showAlert(
NSLocalizedString("Error creating list", comment: "alert title"),
message: responseError(error)
)
}
}
}
}
}
@@ -38,6 +38,7 @@ extension AppSettings {
privacyLinkPreviewsGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
}
if let val = privacyChatListOpenLinks { privacyChatListOpenLinksDefault.set(val) }
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) }
if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
@@ -77,6 +78,7 @@ extension AppSettings {
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
c.privacyChatListOpenLinks = privacyChatListOpenLinksDefault.get()
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
@@ -54,6 +54,13 @@ struct DeveloperView: View {
settingsRow("internaldrive", color: theme.colors.secondary) {
Toggle("Confirm database upgrades", isOn: $confirmDatabaseUpgrades)
}
NavigationLink {
StorageView()
.navigationTitle("Storage")
.navigationBarTitleDisplayMode(.large)
} label: {
settingsRow("internaldrive", color: theme.colors.secondary) { Text("Storage") }
}
} header: {
Text("Developer options")
}
@@ -14,6 +14,7 @@ struct PrivacySettings: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
@State private var chatListOpenLinks = privacyChatListOpenLinksDefault.get()
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@@ -74,6 +75,17 @@ struct PrivacySettings: View {
privacyLinkPreviewsGroupDefault.set(linkPreviews)
}
}
settingsRow("arrow.up.right.circle", color: theme.colors.secondary) {
Picker("Open links from chat list", selection: $chatListOpenLinks) {
ForEach(PrivacyChatListOpenLinksMode.allCases) { mode in
Text(mode.text)
}
}
}
.frame(height: 36)
.onChange(of: chatListOpenLinks) { mode in
privacyChatListOpenLinksDefault.set(mode)
}
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
}
@@ -29,6 +29,7 @@ let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
let DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS = "privacyChatListOpenLinks"
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
@@ -182,6 +183,8 @@ let connectViaLinkTabDefault = EnumDefault<ConnectViaLinkTab>(defaults: UserDefa
let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description)
let privacyChatListOpenLinksDefault = EnumDefault<PrivacyChatListOpenLinksMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS, withDefault: PrivacyChatListOpenLinksMode.ask)
let privacyLocalAuthModeDefault = EnumDefault<LAMode>(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system)
let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET)
@@ -0,0 +1,56 @@
//
// StorageView.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 13.01.2025.
// Copyright © 2025 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct StorageView: View {
@State var appGroupFiles: [String: Int64] = [:]
@State var documentsFiles: [String: Int64] = [:]
var body: some View {
ScrollView {
VStack(alignment: .leading) {
directoryView("App group:", appGroupFiles)
if !documentsFiles.isEmpty {
directoryView("Documents:", documentsFiles)
}
}
}
.padding()
.onAppear {
appGroupFiles = traverseFiles(in: getGroupContainerDirectory())
documentsFiles = traverseFiles(in: getDocumentsDirectory())
}
}
@ViewBuilder
private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View {
Text(name).font(.headline)
ForEach(Array(contents), id: \.key) { (key, value) in
Text(key).bold() + Text(" ") + Text("\(ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))")
}
}
private func traverseFiles(in dir: URL) -> [String: Int64] {
var res: [String: Int64] = [:]
let fm = FileManager.default
do {
if let enumerator = fm.enumerator(at: dir, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .fileAllocatedSizeKey]) {
for case let url as URL in enumerator {
let attrs = try url.resourceValues(forKeys: [/*.isDirectoryKey, .fileSizeKey,*/ .fileAllocatedSizeKey])
let root = String(url.absoluteString.replacingOccurrences(of: dir.absoluteString, with: "").split(separator: "/")[0])
res[root] = (res[root] ?? 0) + Int64(attrs.fileAllocatedSize ?? 0)
}
}
} catch {
logger.error("Error traversing files: \(error)")
}
return res
}
}
@@ -298,6 +298,7 @@ struct UserProfilesView: View {
private func removeUser(_ user: User, _ delSMPQueues: Bool, viewPwd: String?) async {
do {
if user.activeUser {
ChatModel.shared.removeWallpaperFilesFromAllChats(user)
if let newActive = m.users.first(where: { u in !u.user.activeUser && !u.user.hidden }) {
try await changeActiveUserAsync_(newActive.user.userId, viewPwd: nil)
try await deleteUser()
@@ -323,6 +324,7 @@ struct UserProfilesView: View {
func deleteUser() async throws {
try await apiDeleteUser(user.userId, delSMPQueues, viewPwd: viewPwd)
removeWallpaperFilesFromTheme(user.uiThemes)
await MainActor.run { withAnimation { m.removeUser(user) } }
}
}
+16 -8
View File
@@ -167,9 +167,9 @@
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */; };
649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */; };
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */; };
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DC2CFE07CF00536B68 /* libgmp.a */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
@@ -203,9 +203,11 @@
8CB15EA02CFDA30600C28209 /* ChatItemsMerger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */; };
8CB3476C2CF5CFFA006787A5 /* Ink in Frameworks */ = {isa = PBXBuildFile; productRef = 8CB3476B2CF5CFFA006787A5 /* Ink */; };
8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */; };
8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CBC14852D357CDB00BBD901 /* StorageView.swift */; };
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
8CE848A32C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */; };
B70A39732D24090D00E80A5F /* TagListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70A39722D24090D00E80A5F /* TagListView.swift */; };
B728945B2D0C62BF00F7A19A /* ElegantEmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = B728945A2D0C62BF00F7A19A /* ElegantEmojiPicker */; };
B73EFE532CE5FA3500C778EA /* CreateSimpleXAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */; };
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */; };
@@ -520,9 +522,9 @@
648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemForwardingView.swift; sourceTree = "<group>"; };
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
649B28D82CFE07CF00536B68 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a"; sourceTree = "<group>"; };
649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a"; sourceTree = "<group>"; };
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a"; sourceTree = "<group>"; };
649B28DC2CFE07CF00536B68 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
@@ -555,9 +557,11 @@
8CAEF14F2D11A6A000240F00 /* ChatItemsLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsLoader.swift; sourceTree = "<group>"; };
8CB15E9F2CFDA30600C28209 /* ChatItemsMerger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemsMerger.swift; sourceTree = "<group>"; };
8CB3476D2CF5F58B006787A5 /* ConditionsWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConditionsWebView.swift; sourceTree = "<group>"; };
8CBC14852D357CDB00BBD901 /* StorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageView.swift; sourceTree = "<group>"; };
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
8CE848A22C5A0FA000D5C7C8 /* SelectableChatItemToolbars.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableChatItemToolbars.swift; sourceTree = "<group>"; };
B70A39722D24090D00E80A5F /* TagListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagListView.swift; sourceTree = "<group>"; };
B73EFE522CE5FA3500C778EA /* CreateSimpleXAddress.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CreateSimpleXAddress.swift; sourceTree = "<group>"; };
B76E6C302C5C41D900EC11AA /* ContactListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactListNavLink.swift; sourceTree = "<group>"; };
B79ADAFE2CE4EF930083DFFD /* AddressCreationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddressCreationCard.swift; sourceTree = "<group>"; };
@@ -679,9 +683,9 @@
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a in Frameworks */,
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a in Frameworks */,
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a in Frameworks */,
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -765,8 +769,8 @@
649B28D82CFE07CF00536B68 /* libffi.a */,
649B28DC2CFE07CF00536B68 /* libgmp.a */,
649B28DA2CFE07CF00536B68 /* libgmpxx.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-asZoMipGotBmPgAUW0U4t.a */,
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee-ghc9.6.3.a */,
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.0-EDQ0iLPu1OmAZVVQ6Xb0ee.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -954,6 +958,7 @@
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */,
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */,
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */,
8CBC14852D357CDB00BBD901 /* StorageView.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -971,6 +976,7 @@
18415835CBD939A9ABDC108A /* UserPicker.swift */,
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */,
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */,
B70A39722D24090D00E80A5F /* TagListView.swift */,
);
path = ChatList;
sourceTree = "<group>";
@@ -1466,6 +1472,7 @@
8C7F8F0E2C19C0C100D16888 /* ViewModifiers.swift in Sources */,
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C9D13A3282187BB00AB8B43 /* WebRTC.swift in Sources */,
8CBC14862D357CDB00BBD901 /* StorageView.swift in Sources */,
5C9A5BDB2871E05400A5B906 /* SetNotificationsMode.swift in Sources */,
5CB0BA8E2827126500B3292C /* OnboardingView.swift in Sources */,
6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */,
@@ -1537,6 +1544,7 @@
8CB3476E2CF5F58B006787A5 /* ConditionsWebView.swift in Sources */,
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
5C5E5D3B2824468B00B0488A /* ActiveCallView.swift in Sources */,
B70A39732D24090D00E80A5F /* TagListView.swift in Sources */,
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */,
6440CA00288857A10062C672 /* CIEventView.swift in Sources */,
5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */,
+45
View File
@@ -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"
@@ -646,6 +650,7 @@ public enum ChatResponse: Decodable, Error {
case groupEmpty(user: UserRef, groupInfo: GroupInfo)
case userContactLinkSubscribed
case newChatItems(user: UserRef, chatItems: [AChatItem])
case groupChatItemsDeleted(user: UserRef, groupInfo: GroupInfo, chatItemIDs: Set<Int64>, byUser: Bool, member_: GroupMember?)
case forwardPlan(user: UserRef, chatItemIds: [Int64], forwardConfirmation: ForwardConfirmation?)
case chatItemsStatusesUpdated(user: UserRef, chatItems: [AChatItem])
case chatItemUpdated(user: UserRef, chatItem: AChatItem)
@@ -825,6 +830,7 @@ public enum ChatResponse: Decodable, Error {
case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItems: return "newChatItems"
case .groupChatItemsDeleted: return "groupChatItemsDeleted"
case .forwardPlan: return "forwardPlan"
case .chatItemsStatusesUpdated: return "chatItemsStatusesUpdated"
case .chatItemUpdated: return "chatItemUpdated"
@@ -1004,6 +1010,8 @@ public enum ChatResponse: Decodable, Error {
case let .newChatItems(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
return withUser(u, itemsString)
case let .groupChatItemsDeleted(u, gInfo, chatItemIDs, byUser, member_):
return withUser(u, "chatItemIDs: \(String(describing: chatItemIDs))\ngroupInfo: \(String(describing: gInfo))\nbyUser: \(byUser)\nmember_: \(String(describing: member_))")
case let .forwardPlan(u, chatItemIds, forwardConfirmation): return withUser(u, "items: \(chatItemIds) forwardConfirmation: \(String(describing: forwardConfirmation))")
case let .chatItemsStatusesUpdated(u, chatItems):
let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n")
@@ -2225,6 +2233,22 @@ public enum NotificationPreviewMode: String, SelectableItem, Codable {
public static var values: [NotificationPreviewMode] = [.message, .contact, .hidden]
}
public enum PrivacyChatListOpenLinksMode: String, CaseIterable, Codable, RawRepresentable, Identifiable {
case yes
case no
case ask
public var id: Self { self }
public var text: LocalizedStringKey {
switch self {
case .yes: return "Yes"
case .no: return "No"
case .ask: return "Ask"
}
}
}
public struct RemoteCtrlInfo: Decodable {
public var remoteCtrlId: Int64
public var ctrlDeviceName: String
@@ -2489,6 +2513,7 @@ public enum ProtocolErrorType: Decodable, Hashable {
case CMD(cmdErr: ProtocolCommandError)
indirect case PROXY(proxyErr: ProxyError)
case AUTH
case BLOCKED(blockInfo: BlockingInfo)
case CRYPTO
case QUOTA
case STORE(storeErr: String)
@@ -2505,11 +2530,28 @@ public enum ProxyError: Decodable, Hashable {
case NO_SESSION
}
public struct BlockingInfo: Decodable, Equatable, Hashable {
public var reason: BlockingReason
}
public enum BlockingReason: String, Decodable {
case spam
case content
public var text: String {
switch self {
case .spam: NSLocalizedString("Spam", comment: "blocking reason")
case .content: NSLocalizedString("Content violates conditions of use", comment: "blocking reason")
}
}
}
public enum XFTPErrorType: Decodable, Hashable {
case BLOCK
case SESSION
case CMD(cmdErr: ProtocolCommandError)
case AUTH
case BLOCKED(blockInfo: BlockingInfo)
case SIZE
case QUOTA
case DIGEST
@@ -2648,6 +2690,7 @@ public struct AppSettings: Codable, Equatable {
public var privacyAskToApproveRelays: Bool? = nil
public var privacyAcceptImages: Bool? = nil
public var privacyLinkPreviews: Bool? = nil
public var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = nil
public var privacyShowChatPreviews: Bool? = nil
public var privacySaveLastDraft: Bool? = nil
public var privacyProtectScreen: Bool? = nil
@@ -2683,6 +2726,7 @@ public struct AppSettings: Codable, Equatable {
if privacyAskToApproveRelays != def.privacyAskToApproveRelays { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
if privacyAcceptImages != def.privacyAcceptImages { empty.privacyAcceptImages = privacyAcceptImages }
if privacyLinkPreviews != def.privacyLinkPreviews { empty.privacyLinkPreviews = privacyLinkPreviews }
if privacyChatListOpenLinks != def.privacyChatListOpenLinks { empty.privacyChatListOpenLinks = privacyChatListOpenLinks }
if privacyShowChatPreviews != def.privacyShowChatPreviews { empty.privacyShowChatPreviews = privacyShowChatPreviews }
if privacySaveLastDraft != def.privacySaveLastDraft { empty.privacySaveLastDraft = privacySaveLastDraft }
if privacyProtectScreen != def.privacyProtectScreen { empty.privacyProtectScreen = privacyProtectScreen }
@@ -2719,6 +2763,7 @@ public struct AppSettings: Codable, Equatable {
privacyAskToApproveRelays: true,
privacyAcceptImages: true,
privacyLinkPreviews: true,
privacyChatListOpenLinks: .ask,
privacyShowChatPreviews: true,
privacySaveLastDraft: true,
privacyProtectScreen: false,
+166 -23
View File
@@ -9,6 +9,14 @@
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 let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html")!
public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
public var userId: Int64
public var agentUserId: String
@@ -1533,13 +1541,16 @@ public struct ChatData: Decodable, Identifiable, Hashable, ChatLike {
}
public struct ChatStats: Decodable, Hashable {
public init(unreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
public init(unreadCount: Int = 0, reportsCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false) {
self.unreadCount = unreadCount
self.reportsCount = reportsCount
self.minUnreadItemId = minUnreadItemId
self.unreadChat = unreadChat
}
public var unreadCount: Int = 0
// actual only via getChats() and getChat(.initial), otherwise, zero
public var reportsCount: Int = 0
public var minUnreadItemId: Int64 = 0
public var unreadChat: Bool = false
}
@@ -1695,7 +1706,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 +1718,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 +1776,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 +2015,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 } }
@@ -2102,7 +2110,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? {
if !canBeRemoved(groupInfo: groupInfo) { return nil }
let userRole = groupInfo.membership.memberRole
return GroupMemberRole.allCases.filter { $0 <= userRole && $0 != .author }
return GroupMemberRole.supportedRoles.filter { $0 <= userRole }
}
public func canBlockForAll(groupInfo: GroupInfo) -> Bool {
@@ -2110,7 +2118,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 +2149,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 +2169,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 +2193,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 +2604,21 @@ 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 isActiveReport: Bool {
isReport && !isDeletedContent && meta.itemDeleted == nil
}
public var canBeDeletedForSelf: Bool {
(content.msgContent != nil && !meta.isLive) || meta.itemDeleted != nil || isDeletedContent || mergeCategory != nil || showLocalDelete
@@ -2663,6 +2704,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(
@@ -2957,7 +3026,7 @@ public enum SndError: Decodable, Hashable {
case proxyRelay(proxyServer: String, srvError: SrvError)
case other(sndError: String)
public var errorInfo: String {
public var errorInfo: String {
switch self {
case .auth: NSLocalizedString("Wrong key or unknown connection - most likely this connection is deleted.", comment: "snd error text")
case .quota: NSLocalizedString("Capacity exceeded - recipient did not receive previously sent messages.", comment: "snd error text")
@@ -3102,6 +3171,7 @@ public enum CIForwardedFrom: Decodable, Hashable {
public enum CIDeleteMode: String, Decodable, Hashable {
case cidmBroadcast = "broadcast"
case cidmInternal = "internal"
case cidmInternalMark = "internalMark"
}
protocol ItemContent {
@@ -3276,14 +3346,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"
@@ -3347,9 +3415,11 @@ public enum MREmojiChar: String, Codable, CaseIterable, Hashable {
case thumbsup = "👍"
case thumbsdown = "👎"
case smile = "😀"
case laugh = "😂"
case sad = "😢"
case heart = "❤"
case launch = "🚀"
case check = "✅"
}
extension MsgReaction: Decodable {
@@ -3616,6 +3686,7 @@ public enum CIFileStatus: Decodable, Equatable, Hashable {
public enum FileError: Decodable, Equatable, Hashable {
case auth
case blocked(server: String, blockInfo: BlockingInfo)
case noFile
case relay(srvError: SrvError)
case other(fileError: String)
@@ -3623,6 +3694,7 @@ public enum FileError: Decodable, Equatable, Hashable {
var id: String {
switch self {
case .auth: return "auth"
case let .blocked(srv, info): return "blocked \(srv) \(info)"
case .noFile: return "noFile"
case let .relay(srvError): return "relay \(srvError)"
case let .other(fileError): return "other \(fileError)"
@@ -3632,11 +3704,19 @@ public enum FileError: Decodable, Equatable, Hashable {
public var errorInfo: String {
switch self {
case .auth: NSLocalizedString("Wrong key or unknown file chunk address - most likely file is deleted.", comment: "file error text")
case let .blocked(_, info): NSLocalizedString("File is blocked by server operator:\n\(info.reason.text).", comment: "file error text")
case .noFile: NSLocalizedString("File not found - most likely file was deleted or cancelled.", comment: "file error text")
case let .relay(srvError): String.localizedStringWithFormat(NSLocalizedString("File server error: %@", comment: "file error text"), srvError.errorInfo)
case let .other(fileError): String.localizedStringWithFormat(NSLocalizedString("Error: %@", comment: "file error text"), fileError)
}
}
public var moreInfoButton: (label: LocalizedStringKey, link: URL)? {
switch self {
case .blocked: ("How it works", contentModerationPostLink)
default: nil
}
}
}
public enum MsgContent: Equatable, Hashable {
@@ -3646,6 +3726,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)
@@ -3657,6 +3738,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
}
}
@@ -3716,6 +3798,7 @@ public enum MsgContent: Equatable, Hashable {
case preview
case image
case duration
case reason
}
public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool {
@@ -3726,6 +3809,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
}
@@ -3761,6 +3845,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")
@@ -3798,6 +3886,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)
@@ -3877,6 +3969,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) {
+1 -1
View File
@@ -41,7 +41,7 @@ public func getDocumentsDirectory() -> URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
func getGroupContainerDirectory() -> URL {
public func getGroupContainerDirectory() -> URL {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)!
}
+12 -3
View File
@@ -267,17 +267,26 @@ public func saveWallpaperFile(image: UIImage) -> String? {
public func removeWallpaperFile(fileName: String? = nil) {
do {
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: getWallpaperDirectory().path), includingPropertiesForKeys: nil, options: []).forEach { url in
if url.lastPathComponent == fileName {
try FileManager.default.removeItem(at: url)
}
}
} catch {
logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)")
logger.error("FileUtils.removeWallpaperFile error: \(error)")
}
if let fileName {
WallpaperType.cachedImages.removeValue(forKey: fileName)
}
}
public func removeWallpaperFilesFromTheme(_ theme: ThemeModeOverrides?) {
if let theme {
removeWallpaperFile(fileName: theme.light?.wallpaper?.imageFile)
removeWallpaperFile(fileName: theme.dark?.wallpaper?.imageFile)
}
}
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
}
@@ -99,7 +99,8 @@ class CallActivity: ComponentActivity(), ServiceConnection {
fun setPipParams(video: Boolean, sourceRectHint: Rect? = null, viewRatio: Rational? = null) {
// By manually specifying source rect we exclude empty background while toggling PiP
val builder = PictureInPictureParams.Builder()
.setAspectRatio(viewRatio)
// that's limitation of Android. Otherwise, may crash on devices like Z Fold 3
.setAspectRatio(viewRatio?.coerceIn(Rational(100, 239)..Rational(239, 100)))
.setSourceRectHint(sourceRectHint)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(video)
@@ -19,6 +19,8 @@ actual val wallpapersDir: File = File(filesDir.absolutePath + File.separator + "
actual val coreTmpDir: File = File(filesDir.absolutePath + File.separator + "temp_files")
actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "files"
actual val preferencesDir = File(dataDir.absolutePath + File.separator + "shared_prefs")
actual val preferencesTmpDir = File(tmpDir, "prefs_tmp")
.also { it.deleteRecursively() }
actual val chatDatabaseFileName: String = "files_chat.db"
actual val agentDatabaseFileName: String = "files_agent.db"
@@ -29,6 +29,7 @@ actual fun LazyColumnWithScrollBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
fillMaxSize: Boolean,
content: LazyListScope.() -> Unit
@@ -92,6 +93,7 @@ actual fun LazyColumnWithScrollBarNoAppBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
content: LazyListScope.() -> Unit
) {
@@ -14,6 +14,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import chat.simplex.common.AppScreen
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.clear
import chat.simplex.common.model.clearAndNotify
import chat.simplex.common.views.helpers.*
@@ -74,9 +75,16 @@ actual class GlobalExceptionsHandler: Thread.UncaughtExceptionHandler {
if (ModalManager.start.hasModalsOpen()) {
ModalManager.start.closeModal()
} else if (chatModel.chatId.value != null) {
// Since no modals are open, the problem is probably in ChatView
chatModel.chatId.value = null
chatModel.chatItems.clearAndNotify()
withApi {
withChats {
// Since no modals are open, the problem is probably in ChatView
chatModel.chatId.value = null
chatItems.clearAndNotify()
}
withChats {
chatItems.clearAndNotify()
}
}
} else {
// ChatList, nothing to do. Maybe to show other view except ChatList
}
@@ -29,6 +29,19 @@ private val CALL_TOP_GREEN_LINE_HEIGHT = ANDROID_CALL_TOP_PADDING - CALL_TOP_OFF
private val CALL_BOTTOM_ICON_OFFSET = (-15).dp
private val CALL_BOTTOM_ICON_HEIGHT = CALL_INTERACTIVE_AREA_HEIGHT + CALL_BOTTOM_ICON_OFFSET
@Composable
actual fun TagsRow(content: @Composable() (() -> Unit)) {
Row(
modifier = Modifier
.padding(horizontal = 14.dp)
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
content()
}
}
@Composable
actual fun ActiveCallInteractiveArea(call: Call) {
val onClick = { platform.androidStartCallActivity(false) }
@@ -114,7 +114,7 @@ fun MainScreen() {
@Composable
fun AuthView() {
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
@@ -223,7 +223,7 @@ fun MainScreen() {
if (chatModel.controller.appPrefs.performLA.get() && AppLock.laFailed.value) {
AuthView()
} else {
SplashView()
SplashView(true)
ModalManager.fullscreen.showPasscodeInView()
}
} else {
@@ -339,7 +339,7 @@ fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
.graphicsLayer { translationX = maxWidth.toPx() - minOf(offset.value.dp, maxWidth).toPx() }
) Box2@{
currentChatId.value?.let {
ChatView(currentChatId, onComposed)
ChatView(currentChatId, reportsView = false, onComposed = onComposed)
}
}
}
@@ -393,7 +393,7 @@ fun CenterPartOfScreen() {
ModalManager.center.showInView()
}
}
else -> ChatView(currentChatId) {}
else -> ChatView(currentChatId, reportsView = false) {}
}
}
@@ -113,7 +113,7 @@ object AppLock {
val appPrefs = ChatController.appPrefs
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
SetAppPasscodeView(
submit = {
ChatModel.showAuthScreen.value = true
@@ -8,11 +8,11 @@ import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.common.model.ChatModel.chatItemsChangesListener
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.contentModerationPostLink
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrationToDeviceState
@@ -23,7 +23,6 @@ import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.collections.removeAll as remAll
import kotlinx.datetime.*
import kotlinx.datetime.TimeZone
@@ -59,28 +58,20 @@ object ChatModel {
val ctrlInitInProgress = mutableStateOf(false)
val dbMigrationInProgress = mutableStateOf(false)
val incompleteInitializedDbRemoved = mutableStateOf(false)
private val _chats = mutableStateOf(SnapshotStateList<Chat>())
val chats: State<List<Chat>> = _chats
private val chatsContext = ChatsContext()
// map of connections network statuses, key is agent connection id
val networkStatuses = mutableStateMapOf<String, NetworkStatus>()
val switchingUsersAndHosts = mutableStateOf(false)
// current chat
val chatId = mutableStateOf<String?>(null)
/** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on.
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
* If you use api call to get the items, use just [add] instead of [addAndNotify].
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
// set listener here that will be notified on every add/delete of a chat item
var chatItemsChangesListener: ChatItemsChangesListener? = null
val chatState = ActiveChatState()
val chatsContext = ChatsContext(null)
val reportsChatsContext = ChatsContext(MsgContentTag.Report)
// declaration of chatsContext should be before any other variable that is taken from ChatsContext class and used in the model, otherwise, strange crash with NullPointerException for "this" parameter in random functions
val chats: State<List<Chat>> = chatsContext.chats
// rhId, chatId
val deletedChats = mutableStateOf<List<Pair<Long?, String>>>(emptyList())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
val groupMembers = mutableStateListOf<GroupMember>()
val groupMembersIndexes = mutableStateMapOf<Long, Int>()
val groupMembers = mutableStateOf<List<GroupMember>>(emptyList())
val groupMembersIndexes = mutableStateOf<Map<Long, Int>>(emptyMap())
// Chat Tags
val userTags = mutableStateOf(emptyList<ChatTag>())
@@ -157,7 +148,6 @@ object ChatModel {
val updatingProgress = mutableStateOf(null as Float?)
var updatingRequest: Closeable? = null
private val updatingChatsMutex: Mutex = Mutex()
val changingActiveUserMutex: Mutex = Mutex()
val desktopNoUserNoRemote: Boolean @Composable get() = appPlatform.isDesktop && currentUser.value == null && currentRemoteHost.value == null
@@ -177,6 +167,36 @@ object ChatModel {
// return true if you handled the click
var centerPanelBackgroundClickHandler: (() -> Boolean)? = null
fun chatsForContent(contentTag: MsgContentTag?): State<SnapshotStateList<Chat>> = when(contentTag) {
null -> chatsContext.chats
MsgContentTag.Report -> reportsChatsContext.chats
else -> TODO()
}
fun chatItemsForContent(contentTag: MsgContentTag?): State<SnapshotStateList<ChatItem>> = when(contentTag) {
null -> chatsContext.chatItems
MsgContentTag.Report -> reportsChatsContext.chatItems
else -> TODO()
}
fun chatStateForContent(contentTag: MsgContentTag?): ActiveChatState = when(contentTag) {
null -> chatsContext.chatState
MsgContentTag.Report -> reportsChatsContext.chatState
else -> TODO()
}
fun chatItemsChangesListenerForContent(contentTag: MsgContentTag?): ChatItemsChangesListener? = when(contentTag) {
null -> chatsContext.chatItemsChangesListener
MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener
else -> TODO()
}
fun setChatItemsChangeListenerForContent(listener: ChatItemsChangesListener?, contentTag: MsgContentTag?) = when(contentTag) {
null -> chatsContext.chatItemsChangesListener = listener
MsgContentTag.Report -> reportsChatsContext.chatItemsChangesListener = listener
else -> TODO()
}
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value
} else {
@@ -209,7 +229,7 @@ object ChatModel {
for (chat in chats.value.filter { it.remoteHostId == rhId }) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chat.chatInfo)) {
if (presetTagMatchesChat(tag, chat.chatInfo, chat.chatStats)) {
newPresetTags[tag] = (newPresetTags[tag] ?: 0) + 1
}
}
@@ -249,17 +269,17 @@ object ChatModel {
}
}
fun addPresetChatTags(chatInfo: ChatInfo) {
private fun addPresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chatInfo)) {
if (presetTagMatchesChat(tag, chatInfo, chatStats)) {
presetTags[tag] = (presetTags[tag] ?: 0) + 1
}
}
}
fun removePresetChatTags(chatInfo: ChatInfo) {
fun removePresetChatTags(chatInfo: ChatInfo, chatStats: Chat.ChatStats) {
for (tag in PresetTagKind.entries) {
if (presetTagMatchesChat(tag, chatInfo)) {
if (presetTagMatchesChat(tag, chatInfo, chatStats)) {
val count = presetTags[tag]
if (count != null) {
presetTags[tag] = maxOf(0, count - 1)
@@ -268,27 +288,6 @@ object ChatModel {
}
}
fun markChatTagRead(chat: Chat) {
if (chat.unreadTag) {
chat.chatInfo.chatTags?.let { tags ->
markChatTagRead_(chat, tags)
}
}
}
fun updateChatTagRead(chat: Chat, wasUnread: Boolean) {
val tags = chat.chatInfo.chatTags ?: return
val nowUnread = chat.unreadTag
if (nowUnread && !wasUnread) {
tags.forEach { tag ->
unreadTags[tag] = (unreadTags[tag] ?: 0) + 1
}
} else if (!nowUnread && wasUnread) {
markChatTagRead_(chat, tags)
}
}
fun moveChatTagUnread(chat: Chat, oldTags: List<Long>?, newTags: List<Long>) {
if (chat.unreadTag) {
oldTags?.forEach { t ->
@@ -303,45 +302,59 @@ object ChatModel {
}
}
}
private fun markChatTagRead_(chat: Chat, tags: List<Long>) {
for (tag in tags) {
val count = unreadTags[tag]
if (count != null) {
unreadTags[tag] = maxOf(0, count - 1)
}
}
}
// toList() here is to prevent ConcurrentModificationException that is rarely happens but happens
fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null
// TODO pass rhId?
fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
fun populateGroupMembersIndexes() {
groupMembersIndexes.clear()
groupMembers.forEachIndexed { i, member ->
groupMembersIndexes[member.groupMemberId] = i
groupMembersIndexes.value = emptyMap()
val gmIndexes = groupMembersIndexes.value.toMutableMap()
groupMembers.value.forEachIndexed { i, member ->
gmIndexes[member.groupMemberId] = i
}
groupMembersIndexes.value = gmIndexes
}
fun getGroupMember(groupMemberId: Long): GroupMember? {
val memberIndex = groupMembersIndexes[groupMemberId]
val memberIndex = groupMembersIndexes.value[groupMemberId]
return if (memberIndex != null) {
groupMembers[memberIndex]
groupMembers.value[memberIndex]
} else {
null
}
}
suspend fun <T> withChats(action: suspend ChatsContext.() -> T): T = updatingChatsMutex.withLock {
chatsContext.action()
// running everything inside the block on main thread. Make sure any heavy computation is moved to a background thread
suspend fun <T> withChats(contentTag: MsgContentTag? = null, action: suspend ChatsContext.() -> T): T = withContext(Dispatchers.Main) {
when {
contentTag == null -> chatsContext.action()
contentTag == MsgContentTag.Report -> reportsChatsContext.action()
else -> TODO()
}
}
class ChatsContext {
val chats = _chats
suspend fun <T> withReportsChatsIfOpen(action: suspend ChatsContext.() -> T) = withContext(Dispatchers.Main) {
if (ModalManager.end.hasModalOpen(ModalViewId.GROUP_REPORTS)) {
reportsChatsContext.action()
}
}
class ChatsContext(private val contentTag: MsgContentTag?) {
val chats = mutableStateOf(SnapshotStateList<Chat>())
/** if you modify the items by adding/removing them, use helpers methods like [addAndNotify], [removeLastAndNotify], [removeAllAndNotify], [clearAndNotify] and so on.
* If some helper is missing, create it. Notify is needed to track state of items that we added manually (not via api call). See [apiLoadMessages].
* If you use api call to get the items, use just [add] instead of [addAndNotify].
* Never modify underlying list directly because it produces unexpected results in ChatView's LazyColumn (setting by index is ok) */
val chatItems = mutableStateOf(SnapshotStateList<ChatItem>())
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
// set listener here that will be notified on every add/delete of a chat item
var chatItemsChangesListener: ChatItemsChangesListener? = null
val chatState = ActiveChatState()
fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null
fun getChat(id: String): Chat? = chats.value.firstOrNull { it.id == id }
private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId }
suspend fun addChat(chat: Chat) {
chats.add(index = 0, chat)
@@ -380,6 +393,13 @@ object ChatModel {
}
}
fun updateChatStats(rhId: Long?, chatId: ChatId, chatStats: Chat.ChatStats) {
val i = getChatIndex(rhId, chatId)
if (i >= 0) {
chats[i] = chats[i].copy(chatStats = chatStats)
}
}
suspend fun updateContactConnection(rhId: Long?, contactConnection: PendingContactConnection) = updateChat(rhId, ChatInfo.ContactConnection(contactConnection))
suspend fun updateContact(rhId: Long?, contact: Contact) = updateChat(rhId, ChatInfo.Direct(contact), addMissing = contact.directOrUsed)
@@ -397,7 +417,7 @@ object ChatModel {
updateChatInfo(rhId, cInfo)
} else if (addMissing) {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf()))
addPresetChatTags(cInfo)
addPresetChatTags(cInfo, Chat.ChatStats())
}
}
@@ -458,7 +478,7 @@ object ChatModel {
else
chat.chatStats
)
updateChatTagRead(chats[i], wasUnread)
updateChatTagReadNoContentTag(chats[i], wasUnread)
if (appPlatform.isDesktop && cItem.chatDir.sent) {
reorderChat(chats[i], 0)
@@ -474,9 +494,9 @@ object ChatModel {
// Prevent situation when chat item already in the list received from backend
if (chatItems.value.none { it.id == cItem.id }) {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem)
chatItems.addAndNotify(kotlin.math.max(0, chatItems.value.lastIndex), cItem, contentTag)
} else {
chatItems.addAndNotify(cItem)
chatItems.addAndNotify(cItem, contentTag)
}
}
}
@@ -495,7 +515,7 @@ object ChatModel {
chats[i] = chat.copy(chatItems = arrayListOf(cItem))
if (pItem.isRcvNew && !cItem.isRcvNew) {
// status changed from New to Read, update counter
decreaseCounterInChat(rhId, cInfo.id)
decreaseCounterInChatNoContentTag(rhId, cInfo.id)
}
}
res = false
@@ -521,7 +541,7 @@ object ChatModel {
} else {
cItem
}
chatItems.addAndNotify(ci)
chatItems.addAndNotify(ci, contentTag)
true
}
} else {
@@ -546,7 +566,7 @@ object ChatModel {
fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) {
if (cItem.isRcvNew) {
decreaseCounterInChat(rhId, cInfo.id)
decreaseCounterInChatNoContentTag(rhId, cInfo.id)
}
// update previews
val i = getChatIndex(rhId, cInfo.id)
@@ -585,9 +605,9 @@ object ChatModel {
}
}
val popChatCollector = PopChatCollector()
val popChatCollector = PopChatCollector(contentTag)
class PopChatCollector {
class PopChatCollector(contentTag: MsgContentTag?) {
private val subject = MutableSharedFlow<Unit>()
private var remoteHostId: Long? = null
private val chatsToPop = mutableMapOf<ChatId, Instant>()
@@ -597,7 +617,7 @@ object ChatModel {
subject
.throttleLatest(2000)
.collect {
withChats {
withChats(contentTag) {
chats.replaceAll(popCollectedChats())
}
}
@@ -635,11 +655,10 @@ object ChatModel {
}
}
fun markChatItemsRead(remoteHostId: Long?, chatInfo: ChatInfo, itemIds: List<Long>? = null) {
val cInfo = chatInfo
val markedRead = markItemsReadInCurrentChat(chatInfo, itemIds)
fun markChatItemsRead(remoteHostId: Long?, id: ChatId, itemIds: List<Long>? = null) {
val markedRead = markItemsReadInCurrentChat(id, itemIds)
// update preview
val chatIdx = getChatIndex(remoteHostId, cInfo.id)
val chatIdx = getChatIndex(remoteHostId, id)
if (chatIdx >= 0) {
val chat = chats[chatIdx]
val lastId = chat.chatItems.lastOrNull()?.id
@@ -650,12 +669,47 @@ object ChatModel {
chats[chatIdx] = chat.copy(
chatStats = chat.chatStats.copy(unreadCount = unreadCount)
)
updateChatTagRead(chats[chatIdx], wasUnread)
updateChatTagReadNoContentTag(chats[chatIdx], wasUnread)
}
}
}
private fun decreaseCounterInChat(rhId: Long?, chatId: ChatId) {
private fun markItemsReadInCurrentChat(id: ChatId, itemIds: List<Long>? = null): Int {
var markedRead = 0
if (chatId.value == id) {
val items = chatItems.value
var i = items.lastIndex
val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf()
val markedReadIds = mutableSetOf<Long>()
while (i >= 0) {
val item = items[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) {
val newItem = item.withStatus(CIStatus.RcvRead())
items[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
markedReadIds.add(item.id)
markedRead++
if (itemIds != null) {
itemIdsFromRange.remove(item.id)
// already set all needed items as read, can finish the loop
if (itemIdsFromRange.isEmpty()) break
}
}
i--
}
chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items)
}
return markedRead
}
private fun decreaseCounterInChatNoContentTag(rhId: Long?, chatId: ChatId) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
val chatIndex = getChatIndex(rhId, chatId)
if (chatIndex == -1) return
@@ -668,21 +722,15 @@ object ChatModel {
unreadCount = unreadCount,
)
)
updateChatTagRead(chats[chatIndex], wasUnread)
updateChatTagReadNoContentTag(chats[chatIndex], wasUnread)
}
fun removeChat(rhId: Long?, id: String) {
var removed: ChatInfo? = null
chats.removeAll {
val found = it.id == id && it.remoteHostId == rhId
if (found) {
removed = it.chatInfo
}
found
}
removed?.let {
removePresetChatTags(it)
val i = getChatIndex(rhId, id)
if (i != -1) {
val chat = chats.removeAt(i)
removePresetChatTags(chat.chatInfo, chat.chatStats)
removeWallpaperFilesFromChat(chat)
}
}
@@ -694,7 +742,7 @@ object ChatModel {
}
// update current chat
return if (chatId.value == groupInfo.id) {
val memberIndex = groupMembersIndexes[member.groupMemberId]
val memberIndex = groupMembersIndexes.value[member.groupMemberId]
val updated = chatItems.value.map {
// Take into account only specific changes, not all. Other member updates are not important and can be skipped
if (it.chatDir is CIDirection.GroupRcv && it.chatDir.groupMember.groupMemberId == member.groupMemberId &&
@@ -710,12 +758,17 @@ object ChatModel {
if (updated != chatItems.value) {
chatItems.replaceAll(updated)
}
val gMembers = groupMembers.value.toMutableList()
if (memberIndex != null) {
groupMembers[memberIndex] = member
gMembers[memberIndex] = member
groupMembers.value = gMembers
false
} else {
groupMembers.add(member)
groupMembersIndexes[member.groupMemberId] = groupMembers.size - 1
gMembers.add(member)
groupMembers.value = gMembers
val gmIndexes = groupMembersIndexes.value.toMutableMap()
gmIndexes[member.groupMemberId] = groupMembers.size - 1
groupMembersIndexes.value = gmIndexes
true
}
} else {
@@ -731,9 +784,92 @@ object ChatModel {
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
}
private fun getChatIndex(rhId: Long?, id: String): Int = chats.value.indexOfFirst { it.id == id && it.remoteHostId == rhId }
fun increaseUnreadCounter(rhId: Long?, user: UserLike) {
changeUnreadCounterNoContentTag(rhId, user, 1)
}
fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) {
changeUnreadCounterNoContentTag(rhId, user, -by)
}
private fun changeUnreadCounterNoContentTag(rhId: Long?, user: UserLike, by: Int) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId }
if (i != -1) {
users[i] = users[i].copy(unreadCount = users[i].unreadCount + by)
}
}
fun updateChatTagReadNoContentTag(chat: Chat, wasUnread: Boolean) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
val tags = chat.chatInfo.chatTags ?: return
val nowUnread = chat.unreadTag
if (nowUnread && !wasUnread) {
tags.forEach { tag ->
unreadTags[tag] = (unreadTags[tag] ?: 0) + 1
}
} else if (!nowUnread && wasUnread) {
markChatTagReadNoContentTag_(chat, tags)
}
}
fun markChatTagRead(chat: Chat) {
if (chat.unreadTag) {
chat.chatInfo.chatTags?.let { tags ->
markChatTagReadNoContentTag_(chat, tags)
}
}
}
private fun markChatTagReadNoContentTag_(chat: Chat, tags: List<Long>) {
// updates anything only in main ChatView, not GroupReportsView or anything else from the future
if (contentTag != null) return
for (tag in tags) {
val count = unreadTags[tag]
if (count != null) {
unreadTags[tag] = maxOf(0, count - 1)
}
}
}
fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) {
changeGroupReportsCounter(rhId, chatId, 1)
}
fun decreaseGroupReportsCounter(rhId: Long?, chatId: ChatId) {
changeGroupReportsCounter(rhId, chatId, -1)
}
private fun changeGroupReportsCounter(rhId: Long?, chatId: ChatId, by: Int = 0) {
if (by == 0) return
val i = getChatIndex(rhId, chatId)
if (i >= 0) {
val chat = chats.value[i]
chats[i] = chat.copy(
chatStats = chat.chatStats.copy(
reportsCount = (chat.chatStats.reportsCount + by).coerceAtLeast(0),
)
)
val wasReportsCount = chat.chatStats.reportsCount
val nowReportsCount = chats[i].chatStats.reportsCount
val by = if (wasReportsCount == 0 && nowReportsCount > 0) 1 else if (wasReportsCount > 0 && nowReportsCount == 0) -1 else 0
changeGroupReportsTagNoContentTag(by)
}
}
private fun changeGroupReportsTagNoContentTag(by: Int = 0) {
if (by == 0 || contentTag != null) return
presetTags[PresetTagKind.GROUP_REPORTS] = (presetTags[PresetTagKind.GROUP_REPORTS] ?: 0) + by
}
}
fun updateCurrentUser(rhId: Long?, newProfile: Profile, preferences: FullChatPreferences? = null) {
val current = currentUser.value ?: return
@@ -762,79 +898,33 @@ object ChatModel {
suspend fun addLiveDummy(chatInfo: ChatInfo): ChatItem {
val cItem = ChatItem.liveDummy(chatInfo is ChatInfo.Direct)
withContext(Dispatchers.Main) {
chatItems.addAndNotify(cItem)
withChats {
chatItems.addAndNotify(cItem, contentTag = null)
}
return cItem
}
fun removeLiveDummy() {
if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.removeLastAndNotify()
}
}
private fun markItemsReadInCurrentChat(chatInfo: ChatInfo, itemIds: List<Long>? = null): Int {
val cInfo = chatInfo
var markedRead = 0
if (chatId.value == cInfo.id) {
val items = chatItems.value
var i = items.lastIndex
val itemIdsFromRange = itemIds?.toMutableSet() ?: mutableSetOf()
val markedReadIds = mutableSetOf<Long>()
while (i >= 0) {
val item = items[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (itemIds == null || itemIdsFromRange.contains(item.id))) {
val newItem = item.withStatus(CIStatus.RcvRead())
items[i] = newItem
if (newItem.meta.itemLive != true && newItem.meta.itemTimed?.ttl != null) {
items[i] = newItem.copy(meta = newItem.meta.copy(itemTimed = newItem.meta.itemTimed.copy(
deleteAt = Clock.System.now() + newItem.meta.itemTimed.ttl.toDuration(DurationUnit.SECONDS)))
)
}
markedReadIds.add(item.id)
markedRead++
if (itemIds != null) {
itemIdsFromRange.remove(item.id)
// already set all needed items as read, can finish the loop
if (itemIdsFromRange.isEmpty()) break
}
if (chatItemsForContent(null).value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
withApi {
withChats {
chatItems.removeLastAndNotify(contentTag = null)
}
i--
}
chatItemsChangesListener?.read(if (itemIds != null) markedReadIds else null, items)
}
return markedRead
}
fun increaseUnreadCounter(rhId: Long?, user: UserLike) {
changeUnreadCounter(rhId, user, 1)
}
fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) {
changeUnreadCounter(rhId, user, -by)
}
private fun changeUnreadCounter(rhId: Long?, user: UserLike, by: Int) {
val i = users.indexOfFirst { it.user.userId == user.userId && it.user.remoteHostId == rhId }
if (i != -1) {
users[i] = users[i].copy(unreadCount = users[i].unreadCount + by)
}
}
fun getChatItemIndexOrNull(cItem: ChatItem): Int? {
val reversedChatItems = chatItems.asReversed()
fun getChatItemIndexOrNull(cItem: ChatItem, reversedChatItems: List<ChatItem>): Int? {
val index = reversedChatItems.indexOfFirst { it.id == cItem.id }
return if (index != -1) index else null
}
// this function analyses "connected" events and assumes that each member will be there only once
fun getConnectedMemberNames(cItem: ChatItem): Pair<Int, List<String>> {
fun getConnectedMemberNames(cItem: ChatItem, reversedChatItems: List<ChatItem>): Pair<Int, List<String>> {
var count = 0
val ns = mutableListOf<String>()
var idx = getChatItemIndexOrNull(cItem)
var idx = getChatItemIndexOrNull(cItem, reversedChatItems)
if (cItem.mergeCategory != null && idx != null) {
val reversedChatItems = chatItems.asReversed()
while (idx < reversedChatItems.size) {
val ci = reversedChatItems[idx]
if (ci.mergeCategory != cItem.mergeCategory) break
@@ -851,9 +941,8 @@ object ChatModel {
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair<Int?, ChatItem?> {
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?, reversedChatItems: List<ChatItem>): Pair<Int?, ChatItem?> {
var i = ciIndex ?: return null to null
val reversedChatItems = chatItems.asReversed()
val fst = reversedChatItems.lastIndex
while (i < fst) {
i++
@@ -866,8 +955,7 @@ object ChatModel {
}
// returns the previous member in the same merge group and the count of members in this group
fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair<GroupMember?, Int> {
val reversedChatItems = chatItems.asReversed()
fun getPrevHiddenMember(member: GroupMember, range: IntRange, reversedChatItems: List<ChatItem>): Pair<GroupMember?, Int> {
var prevMember: GroupMember? = null
val names: MutableSet<Long> = mutableSetOf()
for (i in range) {
@@ -891,19 +979,25 @@ object ChatModel {
fun replaceConnReqView(id: String, withId: String) {
if (id == showingInvitation.value?.connId) {
showingInvitation.value = null
chatModel.chatItems.clearAndNotify()
chatModel.chatId.value = withId
withApi {
withChats {
showingInvitation.value = null
chatItems.clearAndNotify()
chatModel.chatId.value = withId
}
}
ModalManager.start.closeModals()
ModalManager.end.closeModals()
}
}
fun dismissConnReqView(id: String) {
fun dismissConnReqView(id: String) = withApi {
if (id == showingInvitation.value?.connId) {
showingInvitation.value = null
chatModel.chatItems.clearAndNotify()
chatModel.chatId.value = null
withChats {
showingInvitation.value = null
chatItems.clearAndNotify()
chatModel.chatId.value = null
}
// Close NewChatView
ModalManager.start.closeModals()
ModalManager.center.closeModals()
@@ -1137,7 +1231,13 @@ data class Chat(
}
@Serializable
data class ChatStats(val unreadCount: Int = 0, val minUnreadItemId: Long = 0, val unreadChat: Boolean = false)
data class ChatStats(
val unreadCount: Int = 0,
// actual only via getChats() and getChat(.initial), otherwise, zero
val reportsCount: Int = 0,
val minUnreadItemId: Long = 0,
val unreadChat: Boolean = false
)
companion object {
val sampleData = Chat(
@@ -1334,7 +1434,13 @@ sealed class ChatInfo: SomeChat, NamedChat {
is Group -> groupInfo.chatTags
else -> null
}
}
val contactCard: Boolean
get() = when (this) {
is Direct -> contact.activeConn == null && contact.profile.contactLink != null && contact.active
else -> false
}
}
@Serializable
sealed class NetworkStatus {
@@ -1530,11 +1636,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)
@@ -1655,6 +1757,9 @@ data class GroupInfo (
val canAddMembers: Boolean
get() = membership.memberRole >= GroupMemberRole.Admin && membership.memberActive
val canModerate: Boolean
get() = membership.memberRole >= GroupMemberRole.Moderator && membership.memberActive
companion object {
val sampleData = GroupInfo(
groupId = 1,
@@ -1798,7 +1903,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 {
@@ -1849,13 +1954,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)
}
@@ -2276,6 +2387,14 @@ 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 isActiveReport: Boolean get() = isReport && !isDeletedContent && meta.itemDeleted == null
val canBeDeletedForSelf: Boolean
get() = (content.msgContent != null && !meta.isLive) || meta.itemDeleted != null || isDeletedContent || mergeCategory != null || showLocalDelete
@@ -2492,8 +2611,8 @@ fun MutableState<SnapshotStateList<Chat>>.add(index: Int, elem: Chat) {
value = SnapshotStateList<Chat>().apply { addAll(value); add(index, elem) }
}
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(index: Int, elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, index) }
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(index: Int, elem: ChatItem, contentTag: MsgContentTag?) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(index, elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, index) }
}
fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
@@ -2504,8 +2623,8 @@ fun MutableState<SnapshotStateList<Chat>>.add(elem: Chat) {
fun <T> MutableList<T>.removeAll(predicate: (T) -> Boolean): Boolean = if (isEmpty()) false else remAll(predicate)
// Adds item to chatItems and notifies a listener about newly added item
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(elem: ChatItem) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(elem); chatItemsChangesListener?.added(elem.id to elem.isRcvNew, lastIndex) }
fun MutableState<SnapshotStateList<ChatItem>>.addAndNotify(elem: ChatItem, contentTag: MsgContentTag?) {
value = SnapshotStateList<ChatItem>().apply { addAll(value); add(elem); chatModel.chatItemsChangesListenerForContent(contentTag)?.added(elem.id to elem.isRcvNew, lastIndex) }
}
fun <T> MutableState<SnapshotStateList<T>>.addAll(index: Int, elems: List<T>) {
@@ -2534,7 +2653,8 @@ fun MutableState<SnapshotStateList<ChatItem>>.removeAllAndNotify(block: (ChatIte
}
}
if (toRemove.isNotEmpty()) {
chatItemsChangesListener?.removed(toRemove, value)
chatModel.chatsContext.chatItemsChangesListener?.removed(toRemove, value)
chatModel.reportsChatsContext.chatItemsChangesListener?.removed(toRemove, value)
}
}
@@ -2546,7 +2666,7 @@ fun MutableState<SnapshotStateList<Chat>>.removeAt(index: Int): Chat {
return res
}
fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify() {
fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify(contentTag: MsgContentTag?) {
val removed: Triple<Long, Int, Boolean>
value = SnapshotStateList<ChatItem>().apply {
addAll(value)
@@ -2554,7 +2674,7 @@ fun MutableState<SnapshotStateList<ChatItem>>.removeLastAndNotify() {
val rem = removeLast()
removed = Triple(rem.id, remIndex, rem.isRcvNew)
}
chatItemsChangesListener?.removed(listOf(removed), value)
chatModel.chatItemsChangesListenerForContent(contentTag)?.removed(listOf(removed), value)
}
fun <T> MutableState<SnapshotStateList<T>>.replaceAll(elems: List<T>) {
@@ -2568,7 +2688,8 @@ fun MutableState<SnapshotStateList<Chat>>.clear() {
// Removes all chatItems and notifies a listener about it
fun MutableState<SnapshotStateList<ChatItem>>.clearAndNotify() {
value = SnapshotStateList()
chatItemsChangesListener?.cleared()
chatModel.chatsContext.chatItemsChangesListener?.cleared()
chatModel.reportsChatsContext.chatItemsChangesListener?.cleared()
}
fun <T> State<SnapshotStateList<T>>.asReversed(): MutableList<T> = value.asReversed()
@@ -2696,7 +2817,7 @@ fun getTimestampDateText(t: Instant): String {
val time = t.toLocalDateTime(tz).toJavaLocalDateTime()
val weekday = time.format(DateTimeFormatter.ofPattern("EEE"))
val dayMonthYear = time.format(DateTimeFormatter.ofPattern(
if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM YYYY")
if (Clock.System.now().toLocalDateTime(tz).year == time.year) "d MMM" else "d MMM yyyy")
)
return "$weekday, $dayMonthYear"
@@ -2928,6 +3049,7 @@ sealed class CIForwardedFrom {
@Serializable
enum class CIDeleteMode(val deleteMode: String) {
@SerialName("internal") cidmInternal("internal"),
@SerialName("internalMark") cidmInternalMark("internalMark"),
@SerialName("broadcast") cidmBroadcast("broadcast");
}
@@ -3129,6 +3251,14 @@ sealed class MsgReaction {
companion object {
val values: List<MsgReaction> get() = MREmojiChar.values().map(::Emoji)
val old: List<MsgReaction> get() = listOf(
MREmojiChar.ThumbsUp,
MREmojiChar.ThumbsDown,
MREmojiChar.Smile,
MREmojiChar.Sad,
MREmojiChar.Heart,
MREmojiChar.Launch
).map(::Emoji)
}
}
@@ -3175,9 +3305,11 @@ enum class MREmojiChar(val value: String) {
@SerialName("👍") ThumbsUp("👍"),
@SerialName("👎") ThumbsDown("👎"),
@SerialName("😀") Smile("😀"),
@SerialName("😂") Laugh("😂"),
@SerialName("😢") Sad("😢"),
@SerialName("❤") Heart("❤"),
@SerialName("🚀") Launch("🚀");
@SerialName("🚀") Launch("🚀"),
@SerialName("✅") Check("✅");
}
@Serializable
@@ -3453,15 +3585,22 @@ sealed class CIFileStatus {
@Serializable
sealed class FileError {
@Serializable @SerialName("auth") class Auth: FileError()
@Serializable @SerialName("blocked") class Blocked(val server: String, val blockInfo: BlockingInfo): FileError()
@Serializable @SerialName("noFile") class NoFile: FileError()
@Serializable @SerialName("relay") class Relay(val srvError: SrvError): FileError()
@Serializable @SerialName("other") class Other(val fileError: String): FileError()
val errorInfo: String get() = when (this) {
is FileError.Auth -> generalGetString(MR.strings.file_error_auth)
is FileError.NoFile -> generalGetString(MR.strings.file_error_no_file)
is FileError.Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo)
is FileError.Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError)
is Auth -> generalGetString(MR.strings.file_error_auth)
is Blocked -> generalGetString(MR.strings.file_error_blocked).format(blockInfo.reason.text)
is NoFile -> generalGetString(MR.strings.file_error_no_file)
is Relay -> generalGetString(MR.strings.file_error_relay).format(srvError.errorInfo)
is Other -> generalGetString(MR.strings.ci_status_other_error).format(fileError)
}
val moreInfoButton: Pair<String, String>? get() = when(this) {
is Blocked -> generalGetString(MR.strings.how_it_works) to contentModerationPostLink
else -> null
}
}
@@ -3476,6 +3615,7 @@ sealed class MsgContent {
@Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCReport(override val text: String, val reason: ReportReason): MsgContent()
@Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
val isVoice: Boolean get() =
@@ -3552,6 +3692,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
element("MCFile", buildClassSerialDescriptor("MCFile") {
element<String>("text")
})
element("MCReport", buildClassSerialDescriptor("MCReport") {
element<String>("text")
element<ReportReason>("reason")
})
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
}
@@ -3582,6 +3726,10 @@ object MsgContentSerializer : KSerializer<MsgContent> {
MsgContent.MCVoice(text, duration)
}
"file" -> MsgContent.MCFile(text)
"report" -> {
val reason = Json.decodeFromString<ReportReason>(json["reason"].toString())
MsgContent.MCReport(text, reason)
}
else -> MsgContent.MCUnknown(t, text, json)
}
} else {
@@ -3630,12 +3778,29 @@ object MsgContentSerializer : KSerializer<MsgContent> {
put("type", "file")
put("text", value.text)
}
is MsgContent.MCReport ->
buildJsonObject {
put("type", "report")
put("text", value.text)
put("reason", json.encodeToJsonElement(value.reason))
}
is MsgContent.MCUnknown -> value.json
}
encoder.encodeJsonElement(json)
}
}
@Serializable
enum class MsgContentTag {
@SerialName("text") Text,
@SerialName("link") Link,
@SerialName("image") Image,
@SerialName("video") Video,
@SerialName("voice") Voice,
@SerialName("file") File,
@SerialName("report") Report,
}
@Serializable
class FormattedText(val text: String, val format: Format? = null) {
// TODO make it dependent on simplexLinkMode preference
@@ -3724,6 +3889,58 @@ enum class FormatColor(val color: String) {
}
}
@Serializable(with = ReportReasonSerializer::class)
sealed class ReportReason {
@Serializable @SerialName("spam") object Spam: ReportReason()
@Serializable @SerialName("illegal") object Illegal: ReportReason()
@Serializable @SerialName("community") object Community: ReportReason()
@Serializable @SerialName("profile") object Profile: ReportReason()
@Serializable @SerialName("other") object Other: ReportReason()
@Serializable @SerialName("unknown") data class Unknown(val type: String): ReportReason()
companion object {
val supportedReasons: List<ReportReason> = listOf(Spam, Illegal, Community, Profile, Other)
}
val 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> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ReportReason", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): ReportReason {
return when (val value = decoder.decodeString()) {
"spam" -> ReportReason.Spam
"illegal" -> ReportReason.Illegal
"community" -> ReportReason.Community
"profile" -> ReportReason.Profile
"other" -> ReportReason.Other
else -> ReportReason.Unknown(value)
}
}
override fun serialize(encoder: Encoder, value: ReportReason) {
val stringValue = when (value) {
is ReportReason.Spam -> "spam"
is ReportReason.Illegal -> "illegal"
is ReportReason.Community -> "community"
is ReportReason.Profile -> "profile"
is ReportReason.Other -> "other"
is ReportReason.Unknown -> value.type
}
encoder.encodeString(stringValue)
}
}
@Serializable
class SndFileTransfer() {}
@@ -18,10 +18,13 @@ import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.setNetCfg
import chat.simplex.common.model.ChatModel.changingActiveUserMutex
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.model.SMPErrorType.BLOCKED
import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.item.showContentBlockedAlert
import chat.simplex.common.views.chat.item.showQuotedItemDoesNotExistAlert
import chat.simplex.common.views.chatlist.openGroupChat
import chat.simplex.common.views.migration.MigrationFileLinkData
@@ -46,11 +49,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,
@@ -105,6 +105,7 @@ class AppPreferences {
val privacyProtectScreen = mkBoolPreference(SHARED_PREFS_PRIVACY_PROTECT_SCREEN, true)
val privacyAcceptImages = mkBoolPreference(SHARED_PREFS_PRIVACY_ACCEPT_IMAGES, true)
val privacyLinkPreviews = mkBoolPreference(SHARED_PREFS_PRIVACY_LINK_PREVIEWS, true)
val privacyChatListOpenLinks = mkEnumPreference(SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS, PrivacyChatListOpenLinksMode.ASK) { PrivacyChatListOpenLinksMode.values().firstOrNull { it.name == this } }
private val _simplexLinkMode = mkStrPreference(SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE, SimplexLinkMode.default.name)
val simplexLinkMode: SharedPreference<SimplexLinkMode> = SharedPreference(
get = fun(): SimplexLinkMode {
@@ -373,6 +374,7 @@ class AppPreferences {
private const val SHARED_PREFS_PRIVACY_ACCEPT_IMAGES = "PrivacyAcceptImages"
private const val SHARED_PREFS_PRIVACY_TRANSFER_IMAGES_INLINE = "PrivacyTransferImagesInline"
private const val SHARED_PREFS_PRIVACY_LINK_PREVIEWS = "PrivacyLinkPreviews"
private const val SHARED_PREFS_PRIVACY_CHAT_LIST_OPEN_LINKS = "ChatListOpenLinks"
private const val SHARED_PREFS_PRIVACY_SIMPLEX_LINK_MODE = "PrivacySimplexLinkMode"
private const val SHARED_PREFS_PRIVACY_SHOW_CHAT_PREVIEWS = "PrivacyShowChatPreviews"
private const val SHARED_PREFS_PRIVACY_SAVE_LAST_DRAFT = "PrivacySaveLastDraft"
@@ -683,6 +685,8 @@ object ChatController {
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
}
val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c)
// coroutine was cancelled already, no need to process response (helps with apiListMembers - very heavy query in large groups)
interruptIfCancelled()
val r = APIResponse.decodeStr(json)
if (log) {
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
@@ -894,8 +898,8 @@ object ChatController {
return null
}
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, pagination, search))
suspend fun apiGetChat(rh: Long?, type: ChatType, id: Long, contentTag: MsgContentTag? = null, pagination: ChatPagination, search: String = ""): Pair<Chat, NavigationInfo>? {
val r = sendCmd(rh, CC.ApiGetChat(type, id, contentTag, pagination, search))
if (r is CR.ApiChat) return if (rh == null) r.chat to r.navInfo else r.chat.copy(remoteHostId = rh) to r.navInfo
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
if (pagination is ChatPagination.Around && r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.ChatItemNotFound) {
@@ -976,6 +980,17 @@ object ChatController {
}
}
suspend fun apiReportMessage(rh: Long?, groupId: Long, chatItemId: Long, reportReason: ReportReason, reportText: String): List<AChatItem>? {
val r = sendCmd(rh, CC.ApiReportMessage(groupId, chatItemId, reportReason, reportText))
return when (r) {
is CR.NewChatItems -> r.chatItems
else -> {
apiErrorAlert("apiReportMessage", generalGetString(MR.strings.error_creating_report), r)
null
}
}
}
suspend fun apiGetChatItemInfo(rh: Long?, type: ChatType, id: Long, itemId: Long): ChatItemInfo? {
return when (val r = sendCmd(rh, CC.ApiGetChatItemInfo(type, id, itemId))) {
is CR.ApiChatItemInfo -> r.chatItemInfo
@@ -1400,6 +1415,15 @@ object ChatController {
)
return null
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.SMP
&& r.chatError.agentError.smpErr is SMPErrorType.BLOCKED -> {
showContentBlockedAlert(
generalGetString(MR.strings.connection_error_blocked),
generalGetString(MR.strings.connection_error_blocked_desc).format(r.chatError.agentError.smpErr.blockInfo.reason.text),
)
return null
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorAgent
&& r.chatError.agentError is AgentErrorType.SMP
&& r.chatError.agentError.smpErr is SMPErrorType.QUOTA -> {
@@ -1487,6 +1511,9 @@ object ChatController {
withChats {
clearChat(chat.remoteHostId, updatedChatInfo)
}
withChats(MsgContentTag.Report) {
clearChat(chat.remoteHostId, updatedChatInfo)
}
ntfManager.cancelNotificationsForChat(chat.chatInfo.id)
close?.invoke()
}
@@ -2392,7 +2419,7 @@ object ChatController {
val cInfo = ChatInfo.ContactRequest(contactRequest)
if (active(r.user)) {
withChats {
if (chatModel.hasChat(rhId, contactRequest.id)) {
if (hasChat(rhId, contactRequest.id)) {
updateChatInfo(rhId, cInfo)
} else {
addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = listOf()))
@@ -2402,7 +2429,7 @@ object ChatController {
ntfManager.notifyContactRequestReceived(r.user, cInfo)
}
is CR.ContactUpdated -> {
if (active(r.user) && chatModel.hasChat(rhId, r.toContact.id)) {
if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.toContact.id)) {
val cInfo = ChatInfo.Direct(r.toContact)
withChats {
updateChatInfo(rhId, cInfo)
@@ -2414,10 +2441,13 @@ object ChatController {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.toMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.toMember)
}
}
}
is CR.ContactsMerged -> {
if (active(r.user) && chatModel.hasChat(rhId, r.mergedContact.id)) {
if (active(r.user) && chatModel.chatsContext.hasChat(rhId, r.mergedContact.id)) {
if (chatModel.chatId.value == r.mergedContact.id) {
chatModel.chatId.value = r.intoContact.id
}
@@ -2462,9 +2492,19 @@ object ChatController {
if (active(r.user)) {
withChats {
addChatItem(rhId, cInfo, cItem)
if (cItem.isActiveReport) {
increaseGroupReportsCounter(rhId, cInfo.id)
}
}
withReportsChatsIfOpen {
if (cItem.isReport) {
addChatItem(rhId, cInfo, cItem)
}
}
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
withChats {
increaseUnreadCounter(rhId, r.user)
}
}
val file = cItem.file
val mc = cItem.content.msgContent
@@ -2487,6 +2527,11 @@ object ChatController {
withChats {
updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
}
withReportsChatsIfOpen {
if (cItem.isReport) {
updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
}
}
}
}
is CR.ChatItemUpdated ->
@@ -2496,13 +2541,20 @@ object ChatController {
withChats {
updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
withReportsChatsIfOpen {
if (r.reaction.chatReaction.chatItem.isReport) {
updateChatItem(r.reaction.chatInfo, r.reaction.chatReaction.chatItem)
}
}
}
}
is CR.ChatItemsDeleted -> {
if (!active(r.user)) {
r.chatItemDeletions.forEach { (deletedChatItem, toChatItem) ->
if (toChatItem == null && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled) {
chatModel.decreaseUnreadCounter(rhId, r.user)
withChats {
decreaseUnreadCounter(rhId, r.user)
}
}
}
return
@@ -2531,6 +2583,65 @@ object ChatController {
upsertChatItem(rhId, cInfo, toChatItem.chatItem)
}
}
withReportsChatsIfOpen {
if (cItem.isReport) {
if (toChatItem == null) {
removeChatItem(rhId, cInfo, cItem)
} else {
upsertChatItem(rhId, cInfo, toChatItem.chatItem)
}
}
}
}
}
is CR.GroupChatItemsDeleted -> {
if (!active(r.user)) {
val users = chatController.listUsers(rhId)
chatModel.users.clear()
chatModel.users.addAll(users)
return
}
val cInfo = ChatInfo.Group(r.groupInfo)
withChats {
r.chatItemIDs.forEach { itemId ->
decreaseGroupReportsCounter(rhId, cInfo.id)
val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach
if (chatModel.chatId.value != null) {
// Stop voice playback only inside a chat, allow to play in a chat list
AudioPlayer.stop(cItem)
}
val isLastChatItem = getChat(cInfo.id)?.chatItems?.lastOrNull()?.id == cItem.id
if (isLastChatItem && ntfManager.hasNotificationsForChat(cInfo.id)) {
ntfManager.cancelNotificationsForChat(cInfo.id)
ntfManager.displayNotification(
r.user,
cInfo.id,
cInfo.displayName,
generalGetString(MR.strings.marked_deleted_description)
)
}
val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) {
CIDeleted.Moderated(Clock.System.now(), r.member_)
} else {
CIDeleted.Deleted(Clock.System.now())
}
upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted)))
}
}
withReportsChatsIfOpen {
r.chatItemIDs.forEach { itemId ->
val cItem = chatItems.value.lastOrNull { it.id == itemId } ?: return@forEach
if (chatModel.chatId.value != null) {
// Stop voice playback only inside a chat, allow to play in a chat list
AudioPlayer.stop(cItem)
}
val deleted = if (r.member_ != null && (cItem.chatDir as CIDirection.GroupRcv?)?.groupMember?.groupMemberId != r.member_.groupMemberId) {
CIDeleted.Moderated(Clock.System.now(), r.member_)
} else {
CIDeleted.Deleted(Clock.System.now())
}
upsertChatItem(rhId, cInfo, cItem.copy(meta = cItem.meta.copy(itemDeleted = deleted)))
}
}
}
is CR.ReceivedGroupInvitation -> {
@@ -2596,30 +2707,45 @@ object ChatController {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.deletedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.deletedMember)
}
}
is CR.LeftMember ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberRole ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberRoleUser ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.MemberBlockedForAll ->
if (active(r.user)) {
withChats {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, r.groupInfo, r.member)
}
}
is CR.GroupDeleted -> // TODO update user member
if (active(r.user)) {
@@ -2992,6 +3118,11 @@ object ChatController {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
withChats { upsertChatItem(rh, cInfo, cItem) }
withReportsChatsIfOpen {
if (cItem.isReport) {
upsertChatItem(rh, cInfo, cItem)
}
}
}
}
@@ -3001,10 +3132,14 @@ object ChatController {
val notify = { ntfManager.notifyMessageReceived(rh, user, cInfo, cItem) }
if (!activeUser(rh, user)) {
notify()
} else if (withChats { upsertChatItem(rh, cInfo, cItem) }) {
notify()
} else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) {
notify()
} else {
val createdChat = withChats { upsertChatItem(rh, cInfo, cItem) }
withReportsChatsIfOpen { if (cItem.content.msgContent is MsgContent.MCReport) { upsertChatItem(rh, cInfo, cItem) } }
if (createdChat) {
notify()
} else if (cItem.content is CIContent.RcvCall && cItem.content.status == CICallStatus.Missed) {
notify()
}
}
}
@@ -3044,8 +3179,13 @@ object ChatController {
chatModel.users.addAll(users)
chatModel.currentUser.value = user
if (user == null) {
chatModel.chatItems.clearAndNotify()
withChats {
chatItems.clearAndNotify()
chats.clear()
popChatCollector.clear()
}
withReportsChatsIfOpen {
chatItems.clearAndNotify()
chats.clear()
popChatCollector.clear()
}
@@ -3155,8 +3295,12 @@ class SharedPreference<T>(val get: () -> T, set: (T) -> Unit) {
init {
this.set = { value ->
set(value)
_state.value = value
try {
set(value)
_state.value = value
} catch (e: Exception) {
Log.e(TAG, "Error saving settings: ${e.stackTraceToString()}")
}
}
}
}
@@ -3191,7 +3335,7 @@ sealed class CC {
class ApiGetSettings(val settings: AppSettings): CC()
class ApiGetChatTags(val userId: Long): CC()
class ApiGetChats(val userId: Long): CC()
class ApiGetChat(val type: ChatType, val id: Long, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChat(val type: ChatType, val id: Long, val contentTag: MsgContentTag?, val pagination: ChatPagination, val search: String = ""): CC()
class ApiGetChatItemInfo(val type: ChatType, val id: Long, val itemId: Long): CC()
class ApiSendMessages(val type: ChatType, val id: Long, val live: Boolean, val ttl: Int?, val composedMessages: List<ComposedMessage>): CC()
class ApiCreateChatTag(val tag: ChatTagData): CC()
@@ -3200,6 +3344,7 @@ sealed class CC {
class ApiUpdateChatTag(val tagId: Long, val tagData: ChatTagData): CC()
class ApiReorderChatTags(val tagIds: List<Long>): CC()
class ApiCreateChatItems(val noteFolderId: Long, val composedMessages: List<ComposedMessage>): CC()
class ApiReportMessage(val groupId: Long, val chatItemId: Long, val reportReason: ReportReason, val reportText: String): CC()
class ApiUpdateChatItem(val type: ChatType, val id: Long, val itemId: Long, val mc: MsgContent, val live: Boolean): CC()
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemIds: List<Long>, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val itemIds: List<Long>): CC()
@@ -3352,7 +3497,14 @@ sealed class CC {
is ApiGetSettings -> "/_get app settings ${json.encodeToString(settings)}"
is ApiGetChatTags -> "/_get tags $userId"
is ApiGetChats -> "/_get chats $userId pcc=on"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
is ApiGetChat -> {
val tag = if (contentTag == null) {
""
} else {
" content=${contentTag.name.lowercase()}"
}
"/_get chat ${chatRef(type, id)}$tag ${pagination.cmdString}" + (if (search == "") "" else " search=$search")
}
is ApiGetChatItemInfo -> "/_get item info ${chatRef(type, id)} $itemId"
is ApiSendMessages -> {
val msgs = json.encodeToString(composedMessages)
@@ -3368,6 +3520,7 @@ sealed class CC {
val msgs = json.encodeToString(composedMessages)
"/_create *$noteFolderId json $msgs"
}
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(",")}"
@@ -3531,6 +3684,7 @@ sealed class CC {
is ApiUpdateChatTag -> "apiUpdateChatTag"
is ApiReorderChatTags -> "apiReorderChatTags"
is ApiCreateChatItems -> "apiCreateChatItems"
is ApiReportMessage -> "apiReportMessage"
is ApiUpdateChatItem -> "apiUpdateChatItem"
is ApiDeleteChatItem -> "apiDeleteChatItem"
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
@@ -5523,6 +5677,7 @@ sealed class CR {
@Serializable @SerialName("chatItemReaction") class ChatItemReaction(val user: UserRef, val added: Boolean, val reaction: ACIReaction): CR()
@Serializable @SerialName("reactionMembers") class ReactionMembers(val user: UserRef, val memberReactions: List<MemberReaction>): CR()
@Serializable @SerialName("chatItemsDeleted") class ChatItemsDeleted(val user: UserRef, val chatItemDeletions: List<ChatItemDeletion>, val byUser: Boolean): CR()
@Serializable @SerialName("groupChatItemsDeleted") class GroupChatItemsDeleted(val user: UserRef, val groupInfo: GroupInfo, val chatItemIDs: List<Long>, val byUser: Boolean, val member_: GroupMember?): CR()
@Serializable @SerialName("forwardPlan") class ForwardPlan(val user: UserRef, val itemsCount: Int, val chatItemIds: List<Long>, val forwardConfirmation: ForwardConfirmation? = null): CR()
// group events
@Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR()
@@ -5707,6 +5862,7 @@ sealed class CR {
is ChatItemReaction -> "chatItemReaction"
is ReactionMembers -> "reactionMembers"
is ChatItemsDeleted -> "chatItemsDeleted"
is GroupChatItemsDeleted -> "groupChatItemsDeleted"
is ForwardPlan -> "forwardPlan"
is GroupCreated -> "groupCreated"
is SentGroupInvitation -> "sentGroupInvitation"
@@ -5809,7 +5965,7 @@ sealed class CR {
is ChatRunning -> noDetails()
is ChatStopped -> noDetails()
is ApiChats -> withUser(user, json.encodeToString(chats))
is ApiChat -> withUser(user, "chat: ${json.encodeToString(chat)}\nnavInfo: ${navInfo}")
is ApiChat -> withUser(user, "remoteHostId: ${chat.remoteHostId}\nchatInfo: ${chat.chatInfo}\nchatStats: ${chat.chatStats}\nnavInfo: ${navInfo}\nchatItems: ${chat.chatItems}")
is ChatTags -> withUser(user, "userTags: ${json.encodeToString(userTags)}")
is ApiChatItemInfo -> withUser(user, "chatItem: ${json.encodeToString(chatItem)}\n${json.encodeToString(chatItemInfo)}")
is ServerTestResult -> withUser(user, "server: $testServer\nresult: ${json.encodeToString(testFailure)}")
@@ -5883,6 +6039,7 @@ sealed class CR {
is ChatItemReaction -> withUser(user, "added: $added\n${json.encodeToString(reaction)}")
is ReactionMembers -> withUser(user, "memberReactions: ${json.encodeToString(memberReactions)}")
is ChatItemsDeleted -> withUser(user, "${chatItemDeletions.map { (deletedChatItem, toChatItem) -> "deletedChatItem: ${json.encodeToString(deletedChatItem)}\ntoChatItem: ${json.encodeToString(toChatItem)}" }} \nbyUser: $byUser")
is GroupChatItemsDeleted -> withUser(user, "chatItemIDs: $chatItemIDs\nbyUser: $byUser\nmember_: $member_")
is ForwardPlan -> withUser(user, "itemsCount: $itemsCount\nchatItemIds: ${json.encodeToString(chatItemIds)}\nforwardConfirmation: ${json.encodeToString(forwardConfirmation)}")
is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
@@ -6612,6 +6769,7 @@ sealed class BrokerErrorType {
@Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType()
}
// ProtocolErrorType
@Serializable
sealed class SMPErrorType {
val string: String get() = when (this) {
@@ -6620,9 +6778,10 @@ sealed class SMPErrorType {
is CMD -> "CMD ${cmdErr.string}"
is PROXY -> "PROXY ${proxyErr.string}"
is AUTH -> "AUTH"
is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}"
is CRYPTO -> "CRYPTO"
is QUOTA -> "QUOTA"
is STORE -> "STORE ${storeErr}"
is STORE -> "STORE $storeErr"
is NO_MSG -> "NO_MSG"
is LARGE_MSG -> "LARGE_MSG"
is EXPIRED -> "EXPIRED"
@@ -6633,6 +6792,7 @@ sealed class SMPErrorType {
@Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): SMPErrorType()
@Serializable @SerialName("PROXY") class PROXY(val proxyErr: ProxyError): SMPErrorType()
@Serializable @SerialName("AUTH") class AUTH: SMPErrorType()
@Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): SMPErrorType()
@Serializable @SerialName("CRYPTO") class CRYPTO: SMPErrorType()
@Serializable @SerialName("QUOTA") class QUOTA: SMPErrorType()
@Serializable @SerialName("STORE") class STORE(val storeErr: String): SMPErrorType()
@@ -6656,6 +6816,22 @@ sealed class ProxyError {
@Serializable @SerialName("NO_SESSION") class NO_SESSION: ProxyError()
}
@Serializable
data class BlockingInfo(
val reason: BlockingReason
)
@Serializable
enum class BlockingReason {
@SerialName("spam") Spam,
@SerialName("content") Content;
val text: String get() = when (this) {
Spam -> generalGetString(MR.strings.blocking_reason_spam)
Content -> generalGetString(MR.strings.blocking_reason_content)
}
}
@Serializable
sealed class ProtocolCommandError {
val string: String get() = when (this) {
@@ -6731,6 +6907,7 @@ sealed class XFTPErrorType {
is SESSION -> "SESSION"
is CMD -> "CMD ${cmdErr.string}"
is AUTH -> "AUTH"
is BLOCKED -> "BLOCKED ${json.encodeToString(blockInfo)}"
is SIZE -> "SIZE"
is QUOTA -> "QUOTA"
is DIGEST -> "DIGEST"
@@ -6746,6 +6923,7 @@ sealed class XFTPErrorType {
@Serializable @SerialName("SESSION") object SESSION: XFTPErrorType()
@Serializable @SerialName("CMD") class CMD(val cmdErr: ProtocolCommandError): XFTPErrorType()
@Serializable @SerialName("AUTH") object AUTH: XFTPErrorType()
@Serializable @SerialName("BLOCKED") class BLOCKED(val blockInfo: BlockingInfo): XFTPErrorType()
@Serializable @SerialName("SIZE") object SIZE: XFTPErrorType()
@Serializable @SerialName("QUOTA") object QUOTA: XFTPErrorType()
@Serializable @SerialName("DIGEST") object DIGEST: XFTPErrorType()
@@ -6895,6 +7073,13 @@ enum class NotificationsMode() {
}
}
@Serializable
enum class PrivacyChatListOpenLinksMode {
@SerialName("yes") YES,
@SerialName("no") NO,
@SerialName("ask") ASK
}
@Serializable
data class AppSettings(
var networkConfig: NetCfg? = null,
@@ -6903,6 +7088,7 @@ data class AppSettings(
var privacyAskToApproveRelays: Boolean? = null,
var privacyAcceptImages: Boolean? = null,
var privacyLinkPreviews: Boolean? = null,
var privacyChatListOpenLinks: PrivacyChatListOpenLinksMode? = null,
var privacyShowChatPreviews: Boolean? = null,
var privacySaveLastDraft: Boolean? = null,
var privacyProtectScreen: Boolean? = null,
@@ -6938,6 +7124,7 @@ data class AppSettings(
if (privacyAskToApproveRelays != def.privacyAskToApproveRelays) { empty.privacyAskToApproveRelays = privacyAskToApproveRelays }
if (privacyAcceptImages != def.privacyAcceptImages) { empty.privacyAcceptImages = privacyAcceptImages }
if (privacyLinkPreviews != def.privacyLinkPreviews) { empty.privacyLinkPreviews = privacyLinkPreviews }
if (privacyChatListOpenLinks != def.privacyChatListOpenLinks) { empty.privacyChatListOpenLinks = privacyChatListOpenLinks }
if (privacyShowChatPreviews != def.privacyShowChatPreviews) { empty.privacyShowChatPreviews = privacyShowChatPreviews }
if (privacySaveLastDraft != def.privacySaveLastDraft) { empty.privacySaveLastDraft = privacySaveLastDraft }
if (privacyProtectScreen != def.privacyProtectScreen) { empty.privacyProtectScreen = privacyProtectScreen }
@@ -6984,6 +7171,7 @@ data class AppSettings(
privacyAskToApproveRelays?.let { def.privacyAskToApproveRelays.set(it) }
privacyAcceptImages?.let { def.privacyAcceptImages.set(it) }
privacyLinkPreviews?.let { def.privacyLinkPreviews.set(it) }
privacyChatListOpenLinks?.let { def.privacyChatListOpenLinks.set(it) }
privacyShowChatPreviews?.let { def.privacyShowChatPreviews.set(it) }
privacySaveLastDraft?.let { def.privacySaveLastDraft.set(it) }
privacyProtectScreen?.let { def.privacyProtectScreen.set(it) }
@@ -7020,6 +7208,7 @@ data class AppSettings(
privacyAskToApproveRelays = true,
privacyAcceptImages = true,
privacyLinkPreviews = true,
privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK,
privacyShowChatPreviews = true,
privacySaveLastDraft = true,
privacyProtectScreen = false,
@@ -7057,6 +7246,7 @@ data class AppSettings(
privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(),
privacyAcceptImages = def.privacyAcceptImages.get(),
privacyLinkPreviews = def.privacyLinkPreviews.get(),
privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(),
privacyShowChatPreviews = def.privacyShowChatPreviews.get(),
privacySaveLastDraft = def.privacySaveLastDraft.get(),
privacyProtectScreen = def.privacyProtectScreen.get(),
@@ -3,7 +3,7 @@ package chat.simplex.common.platform
import androidx.compose.runtime.Composable
import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import com.charleskorn.kaml.*
import kotlinx.serialization.encodeToString
@@ -11,6 +11,8 @@ import java.io.*
import java.net.URI
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.file.Files
import java.nio.file.StandardCopyOption
expect val dataDir: File
expect val tmpDir: File
@@ -20,6 +22,7 @@ expect val wallpapersDir: File
expect val coreTmpDir: File
expect val dbAbsolutePrefixPath: String
expect val preferencesDir: File
expect val preferencesTmpDir: File
expect val chatDatabaseFileName: String
expect val agentDatabaseFileName: String
@@ -142,16 +145,23 @@ fun readThemeOverrides(): List<ThemeOverrides> {
}
}
private const val lock = "themesWriter"
fun writeThemeOverrides(overrides: List<ThemeOverrides>): Boolean =
try {
File(getPreferenceFilePath("themes.yaml")).outputStream().use {
val string = yaml.encodeToString(ThemesFile(themes = overrides))
it.bufferedWriter().use { it.write(string) }
synchronized(lock) {
try {
val themesFile = File(getPreferenceFilePath("themes.yaml"))
createTmpFileAndDelete(preferencesTmpDir) { tmpFile ->
val string = yaml.encodeToString(ThemesFile(themes = overrides))
tmpFile.bufferedWriter().use { it.write(string) }
themesFile.parentFile.mkdirs()
Files.move(tmpFile.toPath(), themesFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
true
} catch (e: Exception) {
Log.e(TAG, "Error writing themes file: ${e.stackTraceToString()}")
false
}
true
} catch (e: Throwable) {
Log.e(TAG, "Error while writing themes file: ${e.stackTraceToString()}")
false
}
private fun fileReady(file: CIFile, filePath: String) =
@@ -23,6 +23,7 @@ expect fun LazyColumnWithScrollBar(
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
additionalTopBar: State<Boolean> = remember { mutableStateOf(false) },
chatBottomBar: State<Boolean> = remember { mutableStateOf(true) },
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
@@ -42,6 +43,7 @@ expect fun LazyColumnWithScrollBarNoAppBar(
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
userScrollEnabled: Boolean = true,
additionalBarOffset: State<Dp>? = null,
additionalTopBar: State<Boolean> = remember { mutableStateOf(false) },
chatBottomBar: State<Boolean> = remember { mutableStateOf(true) },
content: LazyListScope.() -> Unit
)
@@ -102,7 +102,9 @@ object ThemeManager {
}
fun applyTheme(theme: String) {
appPrefs.currentTheme.set(theme)
if (appPrefs.currentTheme.get() != theme) {
appPrefs.currentTheme.set(theme)
}
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
platform.androidSetNightModeIfSupported()
val c = CurrentColors.value.colors
@@ -6,11 +6,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun SplashView() {
fun SplashView(nonTransparent: Boolean = false) {
Surface(
Modifier
.fillMaxSize(),
color = MaterialTheme.colors.background,
color = if (nonTransparent) MaterialTheme.colors.background.copy(1f) else MaterialTheme.colors.background,
contentColor = LocalContentColor.current
) {
// Image(
@@ -26,6 +26,7 @@ import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID
import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout
import chat.simplex.common.views.chatlist.NavigationBarBackground
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch
@@ -88,7 +89,7 @@ fun TerminalLayout(
.background(MaterialTheme.colors.background)
) {
Divider()
Box(Modifier.padding(horizontal = 8.dp)) {
Surface(Modifier.padding(horizontal = 8.dp), color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) {
SendMsgView(
composeState = composeState,
showVoiceRecordIcon = false,
@@ -154,12 +155,12 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
}
}
LazyColumnWithScrollBar (
reverseLayout = true,
state = listState,
contentPadding = PaddingValues(
top = topPaddingToContent(false),
bottom = composeViewHeight.value
),
state = listState,
reverseLayout = true,
additionalBarOffset = composeViewHeight
) {
items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item ->
@@ -36,6 +36,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -14,9 +14,10 @@ suspend fun apiLoadSingleMessage(
rhId: Long?,
chatType: ChatType,
apiId: Long,
itemId: Long
itemId: Long,
contentTag: MsgContentTag?,
): ChatItem? = coroutineScope {
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
chat.chatItems.firstOrNull()
}
@@ -24,30 +25,37 @@ suspend fun apiLoadMessages(
rhId: Long?,
chatType: ChatType,
apiId: Long,
contentTag: MsgContentTag?,
pagination: ChatPagination,
chatState: ActiveChatState,
search: String = "",
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
) = coroutineScope {
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last)
|| !isActive) return@coroutineScope
val chatState = chatModel.chatStateForContent(contentTag)
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState
val oldItems = chatModel.chatItems.value
val oldItems = chatModel.chatItemsForContent(contentTag).value
val newItems = SnapshotStateList<ChatItem>()
when (pagination) {
is ChatPagination.Initial -> {
val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList()
withChats {
if (chatModel.getChat(chat.id) == null) {
addChat(chat)
if (contentTag == null) {
// update main chats, not content tagged
withChats {
if (getChat(chat.id) == null) {
addChat(chat)
} else {
updateChatInfo(chat.remoteHostId, chat.chatInfo)
updateChatStats(chat.remoteHostId, chat.id, chat.chatStats)
}
}
}
withContext(Dispatchers.Main) {
chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(chat.chatItems)
withChats(contentTag) {
chatItemStatuses.clear()
chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
splits.value = newSplits
if (chat.chatItems.isNotEmpty()) {
@@ -70,8 +78,8 @@ suspend fun apiLoadMessages(
)
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
newItems.addAll(insertAt, chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = newSplits
chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems)
}
@@ -89,8 +97,8 @@ suspend fun apiLoadMessages(
val indexToAdd = min(indexInCurrentItems + 1, newItems.size)
val indexToAddIsLast = indexToAdd == newItems.size
newItems.addAll(indexToAdd, chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = newSplits
chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems)
// loading clear bottom area, updating number of unread items after the newest loaded item
@@ -104,8 +112,8 @@ suspend fun apiLoadMessages(
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
// currently, items will always be added on top, which is index 0
newItems.addAll(0, chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
withChats(contentTag) {
chatItems.replaceAll(newItems)
splits.value = listOf(chat.chatItems.last().id) + newSplits
unreadAfterItemId.value = chat.chatItems.last().id
totalAfter.value = navInfo.afterTotal
@@ -119,8 +127,8 @@ suspend fun apiLoadMessages(
newItems.addAll(oldItems)
removeDuplicates(newItems, chat)
newItems.addAll(chat.chatItems)
withContext(Dispatchers.Main) {
chatModel.chatItems.replaceAll(newItems)
withChats(contentTag) {
chatItems.replaceAll(newItems)
unreadAfterNewestLoaded.value = 0
}
}
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.chatModel
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
@@ -240,14 +239,13 @@ data class ActiveChatState (
}
}
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, listState: LazyListState): IntRange {
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, reversedItemsSize: Int, listState: LazyListState): IntRange {
val zero = 0 .. 0
if (listState.layoutInfo.totalItemsCount == 0) return zero
val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems
val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed()
if (newest == null || oldest == null) return zero
val size = chatModel.chatItems.value.size
val range = size - oldest .. size - newest
val range = reversedItemsSize - oldest .. reversedItemsSize - newest
if (range.first < 0 || range.last < 0) return zero
// visible items mapped to their underlying data structure which is chatModel.chatItems
@@ -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
@@ -170,6 +193,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview {
is MsgContent.MCVideo -> ComposePreview.MediaPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName))))
is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true)
is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName))
is MsgContent.MCReport -> ComposePreview.NoPreview
is MsgContent.MCUnknown, null -> ComposePreview.NoPreview
}
}
@@ -483,10 +507,24 @@ fun ComposeView(
is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration)
is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration)
is MsgContent.MCFile -> MsgContent.MCFile(msgText)
is MsgContent.MCReport -> MsgContent.MCReport(msgText, reason = msgContent.reason)
is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json)
}
}
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)
@@ -552,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()
@@ -796,7 +836,7 @@ fun ComposeView(
fun editPrevMessage() {
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable }
val lastEditable = chatModel.chatItemsForContent(null).value.findLast { it.meta.editable }
if (lastEditable != null) {
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
}
@@ -833,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) {
@@ -854,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)
}
}
}
@@ -891,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)
@@ -918,154 +984,153 @@ fun ComposeView(
}
}
}
Box(Modifier.background(MaterialTheme.colors.background)) {
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
val attachmentClicked = if (isGroupAndProhibitedFiles) {
{
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.files_and_media_prohibited),
text = generalGetString(MR.strings.only_owners_can_enable_files_and_media)
Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) {
Divider()
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
val attachmentClicked = if (isGroupAndProhibitedFiles) {
{
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.files_and_media_prohibited),
text = generalGetString(MR.strings.only_owners_can_enable_files_and_media)
)
}
} else {
showChooseAttachment
}
val attachmentEnabled =
!composeState.value.attachmentDisabled
&& sendMsgEnabled.value
&& userCanSend.value
&& !isGroupAndProhibitedFiles
&& !nextSendGrpInv.value
IconButton(
attachmentClicked,
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
enabled = attachmentEnabled
) {
Icon(
painterResource(MR.images.ic_attach_file_filled_500),
contentDescription = stringResource(MR.strings.attach),
tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
)
}
} else {
showChooseAttachment
}
val attachmentEnabled =
!composeState.value.attachmentDisabled
&& sendMsgEnabled.value
&& userCanSend.value
&& !isGroupAndProhibitedFiles
&& !nextSendGrpInv.value
IconButton(
attachmentClicked,
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
enabled = attachmentEnabled
) {
Icon(
painterResource(MR.images.ic_attach_file_filled_500),
contentDescription = stringResource(MR.strings.attach),
tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier
.size(28.dp)
.clip(CircleShape)
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when (it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> if (it.durationMs > 300) {
onAudioAdded(it.filePath, it.durationMs, true)
} else {
cancelVoice()
}
is RecordingState.NotStarted -> {}
}
}
}
LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) {
if (!chat.chatInfo.userCanSend) {
clearCurrentDraft()
clearState()
}
}
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
onDispose {
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
}
}
}
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
if (chat.chatInfo.incognito)
if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
else MaterialTheme.colors.primary
SendMsgView(
composeState,
showVoiceRecordIcon = true,
recState,
chat.chatInfo is ChatInfo.Direct,
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
sendMsgEnabled = sendMsgEnabled.value,
sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited),
nextSendGrpInv = nextSendGrpInv.value,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = userIsObserver.value,
userCanSend = userCanSend.value,
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
placeholder = composeState.value.placeholder,
sendMessage = { ttl ->
sendMessage(ttl)
resetLinkPreview()
},
sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null,
updateLiveMessage = ::updateLiveMessage,
cancelLiveMessage = {
composeState.value = composeState.value.copy(liveMessage = null)
chatModel.removeLiveDummy()
},
editPrevMessage = ::editPrevMessage,
onFilesPasted = { composeState.onFilesAttached(it) },
onMessageChange = ::onMessageChange,
textStyle = textStyle
)
}
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
LaunchedEffect(allowedVoiceByPrefs) {
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
// Voice was disabled right when this user records it, just cancel it
cancelVoice()
}
}
val needToAllowVoiceToContact = remember(chat.chatInfo) {
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
contactPreference.allow == FeatureAllowed.YES
}
}
LaunchedEffect(Unit) {
snapshotFlow { recState.value }
.distinctUntilChanged()
.collect {
when (it) {
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
is RecordingState.Finished -> if (it.durationMs > 300) {
onAudioAdded(it.filePath, it.durationMs, true)
} else {
cancelVoice()
}
is RecordingState.NotStarted -> {}
}
}
}
LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) {
if (!chat.chatInfo.userCanSend) {
clearCurrentDraft()
clearState()
}
}
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
val cs = composeState.value
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
sendMessage(null)
resetLinkPreview()
clearPrevDraft(prevChatId)
deleteUnusedFiles()
} else if (cs.inProgress) {
clearPrevDraft(prevChatId)
} else if (!cs.empty) {
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
}
if (saveLastDraft) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = prevChatId
}
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
} else {
clearPrevDraft(prevChatId)
deleteUnusedFiles()
}
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
onDispose {
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
chatModel.draft.value = composeState.value
chatModel.draftChatId.value = chat.id
}
}
}
}
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
val sendButtonColor =
if (chat.chatInfo.incognito)
if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
else MaterialTheme.colors.primary
SendMsgView(
composeState,
showVoiceRecordIcon = true,
recState,
chat.chatInfo is ChatInfo.Direct,
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
sendMsgEnabled = sendMsgEnabled.value,
sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited),
nextSendGrpInv = nextSendGrpInv.value,
needToAllowVoiceToContact,
allowedVoiceByPrefs,
allowVoiceToContact = ::allowVoiceToContact,
userIsObserver = userIsObserver.value,
userCanSend = userCanSend.value,
sendButtonColor = sendButtonColor,
timedMessageAllowed = timedMessageAllowed,
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
placeholder = stringResource(MR.strings.compose_message_placeholder),
sendMessage = { ttl ->
sendMessage(ttl)
resetLinkPreview()
},
sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null,
updateLiveMessage = ::updateLiveMessage,
cancelLiveMessage = {
composeState.value = composeState.value.copy(liveMessage = null)
chatModel.removeLiveDummy()
},
editPrevMessage = ::editPrevMessage,
onFilesPasted = { composeState.onFilesAttached(it) },
onMessageChange = ::onMessageChange,
textStyle = textStyle
)
}
}
}
}
@@ -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]
@@ -21,11 +21,10 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>, onTop: Boolean) {
val onBackClicked = { selectedChatItems.value = null }
BackHandler(onBack = onBackClicked)
val count = selectedChatItems.value?.size ?: 0
val oneHandUI = remember { appPrefs.oneHandUI.state }
DefaultAppBar(
navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) },
title = {
@@ -41,7 +40,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>
)
},
onTitleClick = null,
onTop = !oneHandUI.value,
onTop = onTop,
onSearchValueChanged = {},
)
}
@@ -49,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>
@Composable
fun SelectedItemsBottomToolbar(
chatInfo: ChatInfo,
chatItems: List<ChatItem>,
reversedChatItems: State<List<ChatItem>>,
selectedChatItems: MutableState<Set<Long>?>,
deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible
moderateItems: () -> Unit,
@@ -108,8 +107,8 @@ fun SelectedItemsBottomToolbar(
}
Divider(Modifier.align(Alignment.TopStart))
}
LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) {
recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) {
recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
}
}
@@ -138,10 +137,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
}
}
@@ -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) }
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
import chat.simplex.common.views.helpers.*
@@ -64,6 +65,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
withChats {
upsertGroupMember(rhId, groupInfo, member)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, member)
}
} else {
break
}
@@ -83,7 +87,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
val s = search.trim().lowercase()
val memberContactIds = chatModel.groupMembers
val memberContactIds = chatModel.groupMembers.value
.filter { it.memberCurrent }
.mapNotNull { it.memberContactId }
return chatModel.chats.value
@@ -209,8 +213,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),
@@ -30,6 +30,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -40,12 +41,12 @@ import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.launch
import kotlinx.coroutines.*
const val SMALL_GROUPS_RCPS_MEM_LIMIT: Int = 20
@Composable
fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) {
fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: String, groupLink: String?, groupLinkMemberRole: GroupMemberRole?, scrollToItemId: MutableState<Long?>, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit, close: () -> Unit, onSearchClicked: () -> Unit) {
BackHandler(onBack = close)
// TODO derivedStateOf?
val chat = chatModel.chats.value.firstOrNull { ch -> ch.id == chatId && ch.remoteHostId == rhId }
@@ -54,6 +55,7 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
if (chat != null && chat.chatInfo is ChatInfo.Group && currentUser != null) {
val groupInfo = chat.chatInfo.groupInfo
val sendReceipts = remember { mutableStateOf(SendReceipts.fromBool(groupInfo.chatSettings.sendRcpts, currentUser.sendRcptsSmallGroups)) }
val scope = rememberCoroutineScope()
GroupChatInfoLayout(
chat,
groupInfo,
@@ -64,14 +66,17 @@ fun ModalData.GroupChatInfoView(chatModel: ChatModel, rhId: Long?, chatId: Strin
updateChatSettings(chat.remoteHostId, chat.chatInfo, chatSettings, chatModel)
sendReceipts.value = sendRcpts
},
members = chatModel.groupMembers
members = remember { chatModel.groupMembers }.value
.filter { it.memberStatus != GroupMemberStatus.MemLeft && it.memberStatus != GroupMemberStatus.MemRemoved }
.sortedByDescending { it.memberRole },
developerTools,
groupLink,
scrollToItemId,
addMembers = {
withBGApi {
scope.launch(Dispatchers.Default) {
setGroupMembers(rhId, groupInfo, chatModel)
if (!isActive) return@launch
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(rhId, groupInfo, false, chatModel, close)
}
@@ -195,6 +200,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
withChats {
upsertGroupMember(rhId, groupInfo, updatedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, updatedMember)
}
}
}
},
@@ -279,6 +287,7 @@ fun ModalData.GroupChatInfoLayout(
members: List<GroupMember>,
developerTools: Boolean,
groupLink: String?,
scrollToItemId: MutableState<Long?>,
addMembers: () -> Unit,
showMemberInfo: (GroupMember) -> Unit,
editGroupProfile: () -> Unit,
@@ -306,12 +315,12 @@ fun ModalData.GroupChatInfoLayout(
Box {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LazyColumnWithScrollBar(
state = listState,
contentPadding = if (oneHandUI.value) {
PaddingValues(top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + DEFAULT_PADDING + 5.dp, bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding())
} else {
PaddingValues(top = topPaddingToContent(false))
},
state = listState
}
) {
item {
Row(
@@ -355,6 +364,13 @@ fun ModalData.GroupChatInfoLayout(
}
val prefsTitleId = if (groupInfo.businessChat == null) MR.strings.group_preferences else MR.strings.chat_preferences
GroupPreferencesButton(prefsTitleId, openPreferences)
if (groupInfo.canModerate) {
GroupReportsButton {
scope.launch {
showGroupReportsView(chatModel.chatId, scrollToItemId, chat.chatInfo)
}
}
}
if (members.filter { it.memberCurrent }.size <= SMALL_GROUPS_RCPS_MEM_LIMIT) {
SendReceiptsOption(currentUser, sendReceipts, setSendReceipts)
} else {
@@ -484,6 +500,15 @@ private fun GroupPreferencesButton(titleId: StringResource, onClick: () -> Unit)
)
}
@Composable
private fun GroupReportsButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_flag),
stringResource(MR.strings.group_reports_member_reports),
click = onClick
)
}
@Composable
private fun SendReceiptsOption(currentUser: User, state: State<SendReceipts>, onSelected: (SendReceipts) -> Unit) {
val values = remember {
@@ -734,6 +759,7 @@ fun PreviewGroupChatInfoLayout() {
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
developerTools = false,
groupLink = null,
scrollToItemId = remember { mutableStateOf(null) },
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {},
)
}
@@ -28,6 +28,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
@@ -65,6 +66,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -83,7 +87,7 @@ fun GroupMemberInfoView(
getContactChat = { chatModel.getContactChat(it) },
openDirectChat = {
withBGApi {
apiLoadMessages(rhId, ChatType.Direct, it, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
apiLoadMessages(rhId, ChatType.Direct, it, null, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
if (chatModel.getContactChat(it) != null) {
closeAll()
}
@@ -98,8 +102,8 @@ fun GroupMemberInfoView(
val memberChat = Chat(remoteHostId = rhId, ChatInfo.Direct(memberContact), chatItems = arrayListOf())
withChats {
addChat(memberChat)
openLoadedChat(memberChat)
}
openLoadedChat(memberChat)
closeAll()
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
}
@@ -142,6 +146,9 @@ fun GroupMemberInfoView(
withChats {
upsertGroupMember(rhId, groupInfo, mem)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, mem)
}
}.onFailure {
newRole.value = prevValue
}
@@ -157,6 +164,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -171,6 +181,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -188,6 +201,9 @@ fun GroupMemberInfoView(
withChats {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
withReportsChatsIfOpen {
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
}
close.invoke()
}
}
@@ -203,16 +219,16 @@ fun GroupMemberInfoView(
verify = { code ->
chatModel.controller.apiVerifyGroupMember(rhId, mem.groupId, mem.groupMemberId, code)?.let { r ->
val (verified, existingCode) = r
withChats {
upsertGroupMember(
rhId,
groupInfo,
mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
val copy = mem.copy(
activeConn = mem.activeConn?.copy(
connectionCode = if (verified) SecurityCode(existingCode, Clock.System.now()) else null
)
)
withChats {
upsertGroupMember(rhId, groupInfo, copy)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, copy)
}
r
}
@@ -246,6 +262,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c
withChats {
upsertGroupMember(rhId, groupInfo, removedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, groupInfo, removedMember)
}
}
close?.invoke()
}
@@ -753,6 +772,9 @@ fun updateMemberSettings(rhId: Long?, gInfo: GroupInfo, member: GroupMember, mem
withChats {
upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings))
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, gInfo, member.copy(memberSettings = memberSettings))
}
}
}
}
@@ -786,6 +808,9 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke
withChats {
upsertGroupMember(rhId, gInfo, updatedMember)
}
withReportsChatsIfOpen {
upsertGroupMember(rhId, gInfo, updatedMember)
}
}
}
@@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
updateGroup(rhId, g)
currentPreferences = preferences
}
withChats {
updateGroup(rhId, g)
}
}
afterSave()
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.*
@@ -0,0 +1,106 @@
package chat.simplex.common.views.chat.group
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chatlist.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.*
val LocalContentTag: ProvidableCompositionLocal<MsgContentTag?> = staticCompositionLocalOf { null }
data class GroupReports(
val reportsCount: Int,
val reportsView: Boolean,
) {
val showBar: Boolean = reportsCount > 0 && !reportsView
fun toContentTag(): MsgContentTag? {
if (!reportsView) return null
return MsgContentTag.Report
}
val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report
}
@Composable
private fun GroupReportsView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>) {
ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {})
}
@Composable
fun GroupReportsAppBar(
groupReports: State<GroupReports>,
close: () -> Unit,
onSearchValueChanged: (String) -> Unit
) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val showSearch = rememberSaveable { mutableStateOf(false) }
val onBackClicked = {
if (!showSearch.value) {
close()
} else {
onSearchValueChanged("")
showSearch.value = false
}
}
BackHandler(onBack = onBackClicked)
DefaultAppBar(
navigationButton = { NavigationButtonBack(onBackClicked) },
fixedTitleText = stringResource(MR.strings.group_reports_member_reports),
onTitleClick = null,
onTop = !oneHandUI.value,
showSearch = showSearch.value,
onSearchValueChanged = onSearchValueChanged,
buttons = {
IconButton({ showSearch.value = true }) {
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
}
}
)
ItemsReload(groupReports)
}
@Composable
private fun ItemsReload(groupReports: State<GroupReports>) {
LaunchedEffect(Unit) {
snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged()
.drop(1)
.filterNotNull()
.map { chatModel.getChat(it) }
.filterNotNull()
.filter { it.chatInfo is ChatInfo.Group }
.collect { chat ->
reloadItems(chat, groupReports)
}
}
}
suspend fun showGroupReportsView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>, chatInfo: ChatInfo) {
openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report)
ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close ->
ModalView({}, showAppBar = false) {
val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value
if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) {
GroupReportsView(staleChatId, scrollToItemId)
} else {
LaunchedEffect(Unit) {
close()
}
}
}
}
}
private suspend fun reloadItems(chat: Chat, groupReports: State<GroupReports>) {
val contentFilter = groupReports.value.toContentTag()
apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
}
@@ -27,6 +27,7 @@ import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.chatJsonLength
@@ -13,6 +13,7 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.chat.group.LocalContentTag
@Composable
fun CIChatFeatureView(
@@ -75,9 +76,9 @@ private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List<Feature
val m = ChatModel
val fs: ArrayList<FeatureInfo> = arrayListOf()
val icons: MutableSet<PainterBox> = mutableSetOf()
var i = getChatItemIndexOrNull(chatItem)
val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed()
var i = getChatItemIndexOrNull(chatItem, reversedChatItems)
if (i != null) {
val reversedChatItems = m.chatItems.asReversed()
while (i < reversedChatItems.size) {
val f = featureInfo(reversedChatItems[i], chatInfo) ?: break
if (!icons.contains(f.icon)) {
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chat.item
import SectionItemView
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
@@ -13,6 +14,8 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -92,25 +95,13 @@ fun CIFileView(
FileProtocol.LOCAL -> {}
}
file.fileStatus is CIFileStatus.RcvError ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError)
file.fileStatus is CIFileStatus.RcvWarning ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true)
file.fileStatus is CIFileStatus.SndError ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError)
file.fileStatus is CIFileStatus.SndWarning ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
file.forwardingAllowed() -> {
withLongRunningApi(slow = 600_000) {
var filePath = getLoadedFilePath(file)
@@ -235,6 +226,37 @@ fun CIFileView(
fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol)
fun showFileErrorAlert(err: FileError, temporary: Boolean = false) {
val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error)
val btn = err.moreInfoButton
if (btn != null) {
showContentBlockedAlert(title, err.errorInfo)
} else {
AlertManager.shared.showAlertMsg(title, err.errorInfo)
}
}
val contentModerationPostLink = "https://simplex.chat/blog/20250114-simplex-network-privacy-preserving-content-moderation.html"
fun showContentBlockedAlert(title: String, message: String) {
AlertManager.shared.showAlertDialogButtonsColumn(title, text = message, buttons = {
val uriHandler = LocalUriHandler.current
Column {
SectionItemView({
AlertManager.shared.hideAlert()
uriHandler.openUriCatching(contentModerationPostLink)
}) {
Text(generalGetString(MR.strings.how_it_works), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.ok), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
})
}
@Composable
expect fun SaveOrOpenFileMenu(
showMenu: MutableState<Boolean>,
@@ -238,25 +238,13 @@ fun CIImageView(
FileProtocol.LOCAL -> {}
}
file.fileStatus is CIFileStatus.RcvError ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError)
file.fileStatus is CIFileStatus.RcvWarning ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true)
file.fileStatus is CIFileStatus.SndError ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError)
file.fileStatus is CIFileStatus.SndWarning ->
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
file.fileStatus is CIFileStatus.RcvTransfer -> {} // ?
file.fileStatus is CIFileStatus.RcvComplete -> {} // ?
file.fileStatus is CIFileStatus.RcvCancelled -> {} // TODO
@@ -499,10 +499,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
painterResource(MR.images.ic_close),
MR.strings.icon_descr_file,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError)
}
)
is CIFileStatus.SndWarning ->
@@ -510,10 +507,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
painterResource(MR.images.ic_warning_filled),
MR.strings.icon_descr_file,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
}
)
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive)
@@ -532,10 +526,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
painterResource(MR.images.ic_close),
MR.strings.icon_descr_file,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError)
}
)
is CIFileStatus.RcvWarning ->
@@ -543,10 +534,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
painterResource(MR.images.ic_warning_filled),
MR.strings.icon_descr_file,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true)
}
)
is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
@@ -398,10 +398,7 @@ private fun VoiceMsgIndicator(
sizeMultiplier,
longClick,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError)
}
)
file != null && file.fileStatus is CIFileStatus.SndWarning ->
@@ -411,10 +408,7 @@ private fun VoiceMsgIndicator(
sizeMultiplier,
longClick,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.sndFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
}
)
file?.fileStatus is CIFileStatus.RcvInvitation ->
@@ -430,10 +424,7 @@ private fun VoiceMsgIndicator(
sizeMultiplier,
longClick,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError)
}
)
file != null && file.fileStatus is CIFileStatus.RcvWarning ->
@@ -443,10 +434,7 @@ private fun VoiceMsgIndicator(
sizeMultiplier,
longClick,
onClick = {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.temporary_file_error),
file.fileStatus.rcvFileError.errorInfo
)
showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true)
}
)
file != null && file.loaded && progress != null && duration != null ->
@@ -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,6 +21,7 @@ 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.*
@@ -28,6 +30,7 @@ import chat.simplex.common.model.ChatModel.currentUser
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.group.LocalContentTag
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@@ -255,7 +258,7 @@ fun ChatItemView(
@Composable
fun MsgReactionsMenu() {
val rs = MsgReaction.values.mapNotNull { r ->
val rs = MsgReaction.old.mapNotNull { r ->
if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) {
r
} else {
@@ -295,7 +298,17 @@ 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 && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
ArchiveReportItemAction(cItem, showMenu, deleteMessage)
}
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report))
Divider()
SelectItemAction(showMenu, selectChatItem)
}
}
cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> {
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
@@ -383,9 +396,13 @@ fun ChatItemView(
if (!(live && cItem.meta.isLive) && !preview) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, 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, deleteMessage)
} // else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) {
// ReportItemAction(cItem, composeState, showMenu)
// }
}
if (cItem.canBeDeletedForSelf) {
Divider()
@@ -465,7 +482,7 @@ fun ChatItemView(
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
@@ -500,8 +517,8 @@ fun ChatItemView(
DeleteItemMenu()
}
fun mergedGroupEventText(chatItem: ChatItem): String? {
val (count, ns) = chatModel.getConnectedMemberNames(chatItem)
fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List<ChatItem>): String? {
val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems)
val members = when {
ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0])
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
@@ -520,9 +537,9 @@ fun ChatItemView(
}
}
fun eventItemViewText(): AnnotatedString {
fun eventItemViewText(reversedChatItems: List<ChatItem>): AnnotatedString {
val memberDisplayName = cItem.memberDisplayName
val t = mergedGroupEventText(cItem)
val t = mergedGroupEventText(cItem, reversedChatItems)
return if (!revealed.value && t != null) {
chatEventText(t, cItem.timestampText)
} else if (memberDisplayName != null) {
@@ -536,12 +553,13 @@ fun ChatItemView(
}
@Composable fun EventItemView() {
CIEventView(eventItemViewText())
val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed()
CIEventView(eventItemViewText(reversedChatItems))
}
@Composable
fun DeletedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
MarkedDeletedItemView(cItem, cInfo, 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)
@@ -728,21 +746,23 @@ fun DeleteItemAction(
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
buttonText: String = stringResource(MR.strings.delete_verb),
) {
val contentTag = LocalContentTag.current
ItemAction(
stringResource(MR.strings.delete_verb),
buttonText,
painterResource(MR.images.ic_delete),
onClick = {
showMenu.value = false
if (!revealed.value) {
val currIndex = chatModel.getChatItemIndexOrNull(cItem)
val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed()
val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems)
val ciCategory = cItem.mergeCategory
if (currIndex != null && ciCategory != null) {
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems)
val range = chatViewItemsRange(currIndex, prevHidden)
if (range != null) {
val itemIds: ArrayList<Long> = arrayListOf()
val reversedChatItems = chatModel.chatItems.asReversed()
for (i in range) {
itemIds.add(reversedChatItems[i].id)
}
@@ -847,6 +867,73 @@ 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 ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState<Boolean>, deleteMessage: (Long, CIDeleteMode) -> Unit) {
ItemAction(
stringResource(MR.strings.archive_report),
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 +1220,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)
@@ -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(
@@ -128,17 +128,6 @@ fun FramedItemView(
Modifier
.background(if (sent) sentColor else receivedColor)
.fillMaxWidth()
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = {
if (qi.itemId != null) {
scrollToItem(qi.itemId)
} else {
scrollToQuotedItemFromItem(ci.id)
}
}
)
.onRightClick { showMenu.value = true }
) {
when (qi.content) {
is MsgContent.MCImage -> {
@@ -216,28 +205,66 @@ 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) {
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))
@Composable
fun Header() {
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 {
val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) {
stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName)
} else {
stringResource(MR.strings.report_item_archived)
}
FramedItemHeader(text, true, painterResource(MR.images.ic_flag))
}
is CIDeleted.Blocked -> {
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.BlockedByAdmin -> {
FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.Deleted -> {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
} 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))
}
is CIDeleted.Blocked -> {
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.BlockedByAdmin -> {
FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand))
}
is CIDeleted.Deleted -> {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
}
}
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(MR.strings.live), false)
}
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(MR.strings.live), false)
}
if (ci.quotedItem != null) {
ciQuoteView(ci.quotedItem)
} else if (ci.meta.itemForwarded != null) {
FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
Column(
Modifier
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = {
if (ci.quotedItem.itemId != null) {
scrollToItem(ci.quotedItem.itemId)
} else {
scrollToQuotedItemFromItem(ci.id)
}
}
)
.onRightClick { showMenu.value = true }
) {
Header()
ciQuoteView(ci.quotedItem)
}
} else {
Header()
if (ci.meta.itemForwarded != null) {
FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
}
}
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
@@ -288,6 +315,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 +350,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
)
}
}
@@ -12,15 +12,17 @@ import androidx.compose.runtime.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.chatModel
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.group.LocalContentTag
import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.datetime.Clock
@Composable
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
val sentColor = MaterialTheme.appColors.sentMessage
val receivedColor = MaterialTheme.appColors.receivedMessage
Surface(
@@ -33,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f, false)) {
MergedMarkedDeletedText(ci, revealed)
MergedMarkedDeletedText(ci, chatInfo, revealed)
}
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
@@ -41,11 +43,11 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<
}
@Composable
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>) {
var i = getChatItemIndexOrNull(chatItem)
private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State<Boolean>) {
val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed()
var i = getChatItemIndexOrNull(chatItem, reversedChatItems)
val ciCategory = chatItem.mergeCategory
val text = if (!revealed.value && ciCategory != null && i != null) {
val reversedChatItems = ChatModel.chatItems.asReversed()
var moderated = 0
var blocked = 0
var blockedByAdmin = 0
@@ -67,7 +69,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
}
val total = moderated + blocked + blockedByAdmin + deleted
if (total <= 1)
markedDeletedText(chatItem.meta)
markedDeletedText(chatItem, chatInfo)
else if (total == moderated)
stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", "))
else if (total == blockedByAdmin)
@@ -77,7 +79,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, chatInfo)
}
Text(
@@ -91,10 +93,17 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
)
}
fun markedDeletedText(meta: CIMeta): String =
when (meta.itemDeleted) {
fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String =
if (cItem.meta.itemDeleted != null && cItem.isReport) {
if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) {
generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName)
} else {
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 ->
@@ -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) {
@@ -10,24 +10,17 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.MaterialTheme.colors
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.markChatTagRead
import chat.simplex.common.model.ChatModel.updateChatTagRead
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
@@ -71,13 +64,14 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
when (chat.chatInfo) {
is ChatInfo.Direct -> {
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
val defaultClickAction = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } }
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false)
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, disabled, linkMode, inProgress = false, progressByTimeout = false, defaultClickAction)
}
},
click = { if (chatModel.chatId.value != chat.id) scope.launch { directChatAction(chat.remoteHostId, chat.chatInfo.contact, chatModel) } },
click = defaultClickAction,
dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
ContactMenuItems(chat, chat.chatInfo.contact, chatModel, showMenu, showMarkRead)
@@ -89,14 +83,15 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
nextChatSelected,
)
}
is ChatInfo.Group ->
is ChatInfo.Group -> {
val defaultClickAction = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } }
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout)
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress.value, progressByTimeout, defaultClickAction)
}
},
click = { if (!inProgress.value && chatModel.chatId.value != chat.id) scope.launch { groupChatAction(chat.remoteHostId, chat.chatInfo.groupInfo, chatModel, inProgress) } },
click = defaultClickAction,
dropdownMenuItems = {
tryOrShowError("${chat.id}ChatListNavLinkDropdown", error = {}) {
GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead)
@@ -107,11 +102,12 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
selectedChat,
nextChatSelected,
)
}
is ChatInfo.Local -> {
ChatListNavLinkLayout(
chatLinkPreview = {
tryOrShowError("${chat.id}ChatListNavLink", error = { ErrorChatListItem() }) {
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false)
ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, disabled, linkMode, inProgress = false, progressByTimeout = false, {})
}
},
click = { if (chatModel.chatId.value != chat.id) scope.launch { noteFolderChatAction(chat.remoteHostId, chat.chatInfo.noteFolder) } },
@@ -209,28 +205,33 @@ suspend fun noteFolderChatAction(rhId: Long?, noteFolder: NoteFolder) = openChat
suspend fun openDirectChat(rhId: Long?, contactId: Long) = openChat(rhId, ChatType.Direct, contactId)
suspend fun openGroupChat(rhId: Long?, groupId: Long) = openChat(rhId, ChatType.Group, groupId)
suspend fun openGroupChat(rhId: Long?, groupId: Long, contentTag: MsgContentTag? = null) = openChat(rhId, ChatType.Group, groupId, contentTag)
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo) = openChat(rhId, chatInfo.chatType, chatInfo.apiId)
suspend fun openChat(rhId: Long?, chatInfo: ChatInfo, contentTag: MsgContentTag? = null) = openChat(rhId, chatInfo.chatType, chatInfo.apiId, contentTag)
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long) =
apiLoadMessages(rhId, chatType, apiId, ChatPagination.Initial(ChatPagination.INITIAL_COUNT), chatModel.chatState)
private suspend fun openChat(rhId: Long?, chatType: ChatType, apiId: Long, contentTag: MsgContentTag? = null) =
apiLoadMessages(rhId, chatType, apiId, contentTag, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
fun openLoadedChat(chat: Chat) {
chatModel.chatItemStatuses.clear()
chatModel.chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
chatModel.chatState.clear()
suspend fun openLoadedChat(chat: Chat, contentTag: MsgContentTag? = null) {
withChats(contentTag) {
chatItemStatuses.clear()
chatItems.replaceAll(chat.chatItems)
chatModel.chatId.value = chat.chatInfo.id
chatModel.chatStateForContent(contentTag).clear()
}
}
suspend fun apiFindMessages(ch: Chat, search: String) {
chatModel.chatItems.clearAndNotify()
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), chatModel.chatState, search = search)
suspend fun apiFindMessages(ch: Chat, search: String, contentTag: MsgContentTag?) {
withChats(contentTag) {
chatItems.clearAndNotify()
}
apiLoadMessages(ch.remoteHostId, ch.chatInfo.chatType, ch.chatInfo.apiId, contentTag, pagination = ChatPagination.Last(ChatPagination.INITIAL_COUNT), search = search)
}
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
// groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled
val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId)
val currentMembers = chatModel.groupMembers
val currentMembers = chatModel.groupMembers.value
val newMembers = groupMembers.map { newMember ->
val currentMember = currentMembers.find { it.id == newMember.id }
val currentMemberStats = currentMember?.activeConn?.connectionStats
@@ -241,9 +242,8 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
newMember
}
}
chatModel.groupMembers.clear()
chatModel.groupMembersIndexes.clear()
chatModel.groupMembers.addAll(newMembers)
chatModel.groupMembersIndexes.value = emptyMap()
chatModel.groupMembers.value = newMembers
chatModel.populateGroupMembersIndexes()
}
@@ -251,7 +251,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (contact.activeConn != null) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
MarkReadChatAction(chat, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
@@ -291,7 +291,7 @@ fun GroupMenuItems(
}
else -> {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
MarkReadChatAction(chat, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
@@ -312,7 +312,7 @@ fun GroupMenuItems(
@Composable
fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
if (showMarkRead) {
MarkReadChatAction(chat, chatModel, showMenu)
MarkReadChatAction(chat, showMenu)
} else {
MarkUnreadChatAction(chat, chatModel, showMenu)
}
@@ -320,12 +320,12 @@ fun NoteFolderMenuItems(chat: Chat, showMenu: MutableState<Boolean>, showMarkRea
}
@Composable
fun MarkReadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
fun MarkReadChatAction(chat: Chat, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.mark_read),
painterResource(MR.images.ic_check),
onClick = {
markChatRead(chat, chatModel)
markChatRead(chat)
ntfManager.cancelNotificationsForChat(chat.id)
showMenu.value = false
}
@@ -351,14 +351,14 @@ fun TagListAction(
) {
val userTags = remember { chatModel.userTags }
ItemAction(
stringResource(MR.strings.list_menu),
stringResource(if (chat.chatInfo.chatTags.isNullOrEmpty()) MR.strings.add_to_list else MR.strings.change_list),
painterResource(MR.images.ic_label),
onClick = {
ModalManager.start.showModalCloseable { close ->
if (userTags.value.isEmpty()) {
TagListEditor(rhId = chat.remoteHostId, chat = chat, close = close)
} else {
TagListView(rhId = chat.remoteHostId, chat = chat, close = close)
TagListView(rhId = chat.remoteHostId, chat = chat, close = close, reorderMode = false)
}
}
showMenu.value = false
@@ -562,12 +562,15 @@ private fun InvalidDataView() {
}
}
fun markChatRead(c: Chat, chatModel: ChatModel) {
fun markChatRead(c: Chat) {
var chat = c
withApi {
if (chat.chatStats.unreadCount > 0) {
withChats {
markChatItemsRead(chat.remoteHostId, chat.chatInfo)
markChatItemsRead(chat.remoteHostId, chat.chatInfo.id)
}
withReportsChatsIfOpen {
markChatItemsRead(chat.remoteHostId, chat.chatInfo.id)
}
chatModel.controller.apiChatRead(
chat.remoteHostId,
@@ -608,7 +611,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
if (success) {
withChats {
replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
updateChatTagRead(chat, wasUnread)
updateChatTagReadNoContentTag(chat, wasUnread)
}
}
}
@@ -870,7 +873,9 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch
}
val updatedChat = chatModel.getChat(chatInfo.id)
if (updatedChat != null) {
chatModel.updateChatTagRead(updatedChat, wasUnread)
withChats {
updateChatTagReadNoContentTag(updatedChat, wasUnread)
}
}
val current = currentState?.value
if (current != null) {
@@ -923,7 +928,8 @@ fun PreviewChatListNavLinkDirect() {
disabled = false,
linkMode = SimplexLinkMode.DESCRIPTION,
inProgress = false,
progressByTimeout = false
progressByTimeout = false,
{}
)
},
click = {},
@@ -968,7 +974,8 @@ fun PreviewChatListNavLinkGroup() {
disabled = false,
linkMode = SimplexLinkMode.DESCRIPTION,
inProgress = false,
progressByTimeout = false
progressByTimeout = false,
{}
)
},
click = {},
@@ -49,7 +49,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import kotlin.time.Duration.Companion.seconds
enum class PresetTagKind { FAVORITES, CONTACTS, GROUPS, BUSINESS }
enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES }
sealed class ActiveFilter {
data class PresetTag(val tag: PresetTagKind) : ActiveFilter()
@@ -152,7 +152,7 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
if (appPlatform.isDesktop) {
KeyChangeEffect(chatModel.chatId.value) {
if (chatModel.chatId.value != null) {
if (chatModel.chatId.value != null && !ModalManager.end.isLastModalOpen(ModalViewId.GROUP_REPORTS)) {
ModalManager.end.closeModalsExceptFirst()
}
AudioPlayer.stop()
@@ -815,13 +815,13 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
if (oneHandUI.value) {
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
Divider()
TagsView()
TagsView(searchText)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
} else {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
TagsView()
TagsView(searchText)
Divider()
}
}
@@ -925,35 +925,28 @@ private fun ChatListFeatureCards() {
private val TAG_MIN_HEIGHT = 35.dp
@Composable
private fun TagsView() {
private fun TagsView(searchText: MutableState<TextFieldValue>) {
val userTags = remember { chatModel.userTags }
val presetTags = remember { chatModel.presetTags }
val collapsiblePresetTags = presetTags.filter { presetCanBeCollapsed(it.key) && it.value > 0 }
val alwaysShownPresetTags = presetTags.filter { !presetCanBeCollapsed(it.key) && it.value > 0 }
val activeFilter = remember { chatModel.activeChatTagFilter }
val unreadTags = remember { chatModel.unreadTags }
val rhId = chatModel.remoteHostId()
fun showTagList() {
ModalManager.start.showCustomModal { close ->
val editMode = remember { stateGetOrPut("editMode") { false } }
ModalView(close, showClose = true, endButtons = {
TextButton(onClick = { editMode.value = !editMode.value }, modifier = Modifier.clip(shape = CircleShape)) {
Text(stringResource(if (editMode.value) MR.strings.cancel_verb else MR.strings.edit_verb))
}
}) {
TagListView(rhId = rhId, close = close, editMode = editMode)
}
}
}
val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
TagsRow {
if (presetTags.size > 1) {
if (presetTags.size + userTags.value.size <= 3) {
if (collapsiblePresetTags.size > 1) {
if (collapsiblePresetTags.size + alwaysShownPresetTags.size + userTags.value.size <= 3) {
PresetTagKind.entries.filter { t -> (presetTags[t] ?: 0) > 0 }.forEach { tag ->
ExpandedTagFilterView(tag)
}
} else {
CollapsedTagsFilterView()
CollapsedTagsFilterView(searchText)
alwaysShownPresetTags.forEach { tag ->
ExpandedTagFilterView(tag.key)
}
}
}
@@ -963,69 +956,75 @@ private fun TagsView() {
else -> false
}
val interactionSource = remember { MutableInteractionSource() }
Row(
rowSizeModifier
.clip(shape = CircleShape)
.combinedClickable(
onClick = {
if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) {
chatModel.activeChatTagFilter.value = null
} else {
chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag)
val showMenu = rememberSaveable { mutableStateOf(false) }
val saving = remember { mutableStateOf(false) }
Box {
Row(
rowSizeModifier
.clip(shape = CircleShape)
.combinedClickable(
onClick = {
if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) {
chatModel.activeChatTagFilter.value = null
} else {
chatModel.activeChatTagFilter.value = ActiveFilter.UserTag(tag)
}
},
onLongClick = { showMenu.value = true },
interactionSource = interactionSource,
indication = LocalIndication.current,
enabled = !saving.value
)
.onRightClick { showMenu.value = true }
.padding(4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (tag.chatTagEmoji != null) {
ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp)
} else {
Icon(
painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label),
null,
Modifier.size(18.sp.toDp()),
tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground
)
}
Spacer(Modifier.width(4.dp))
Box {
val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else ""
val invisibleText = buildAnnotatedString {
append(tag.chatTagText)
withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) {
append(badgeText)
}
},
onLongClick = { showTagList() },
interactionSource = interactionSource,
indication = LocalIndication.current
)
.onRightClick { showTagList() }
.padding(4.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (tag.chatTagEmoji != null) {
ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp)
} else {
Icon(
painterResource(if (current) MR.images.ic_label_filled else MR.images.ic_label),
null,
Modifier.size(18.sp.toDp()),
tint = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground
)
}
Spacer(Modifier.width(4.dp))
Box {
val badgeText = if ((unreadTags[tag.chatTagId] ?: 0) > 0) " ●" else ""
val invisibleText = buildAnnotatedString {
append(tag.chatTagText)
withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.SemiBold)) {
append(badgeText)
}
}
Text(
text = invisibleText,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
color = Color.Transparent,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Visible text with styles
val visibleText = buildAnnotatedString {
append(tag.chatTagText)
withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) {
append(badgeText)
Text(
text = invisibleText,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
color = Color.Transparent,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// Visible text with styles
val visibleText = buildAnnotatedString {
append(tag.chatTagText)
withStyle(SpanStyle(fontSize = 12.5.sp, color = MaterialTheme.colors.primary)) {
append(badgeText)
}
}
Text(
text = visibleText,
fontWeight = if (current) FontWeight.Medium else FontWeight.Normal,
fontSize = 15.sp,
color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = visibleText,
fontWeight = if (current) FontWeight.Medium else FontWeight.Normal,
fontSize = 15.sp,
color = if (current) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
TagsDropdownMenu(rhId, tag, showMenu, saving)
}
}
val plusClickModifier = Modifier
@@ -1051,23 +1050,8 @@ private fun TagsView() {
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun TagsRow(content: @Composable() (() -> Unit)) {
if (appPlatform.isAndroid) {
Row(
modifier = Modifier
.padding(horizontal = 14.dp)
.horizontalScroll(rememberScrollState()),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
content()
}
} else {
FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() }
}
}
expect fun TagsRow(content: @Composable() (() -> Unit))
@Composable
private fun ExpandedTagFilterView(tag: PresetTagKind) {
@@ -1076,12 +1060,12 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) {
is ActiveFilter.PresetTag -> af.tag == tag
else -> false
}
val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
val (icon, text) = presetTagLabel(tag, active)
val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
Row(
modifier = rowSizeModifier
modifier = Modifier
.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
.clip(shape = CircleShape)
.clickable {
if (activeFilter.value == ActiveFilter.PresetTag(tag)) {
@@ -1121,21 +1105,19 @@ private fun ExpandedTagFilterView(tag: PresetTagKind) {
@Composable
private fun CollapsedTagsFilterView() {
private fun CollapsedTagsFilterView(searchText: MutableState<TextFieldValue>) {
val activeFilter = remember { chatModel.activeChatTagFilter }
val presetTags = remember { chatModel.presetTags }
val showMenu = remember { mutableStateOf(false) }
val selectedPresetTag = when (val af = activeFilter.value) {
is ActiveFilter.PresetTag -> af.tag
is ActiveFilter.PresetTag -> if (presetCanBeCollapsed(af.tag)) af.tag else null
else -> null
}
val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
Box(rowSizeModifier
.padding(vertical = 4.dp)
Box(Modifier
.clip(shape = CircleShape)
.size(30.sp.toDp())
.size(TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
.clickable { showMenu.value = true },
contentAlignment = Alignment.Center
) {
@@ -1145,7 +1127,7 @@ private fun CollapsedTagsFilterView() {
painterResource(icon),
stringResource(text),
Modifier.size(18.sp.toDp()),
tint = MaterialTheme.colors.secondary
tint = MaterialTheme.colors.primary
)
} else {
Icon(
@@ -1155,20 +1137,26 @@ private fun CollapsedTagsFilterView() {
)
}
DefaultDropdownMenu(showMenu = showMenu) {
if (selectedPresetTag != null) {
val onCloseMenuAction = remember { mutableStateOf<(() -> Unit)>({}) }
DefaultDropdownMenu(showMenu = showMenu, onClosed = onCloseMenuAction) {
if (activeFilter.value != null || searchText.value.text.isNotBlank()) {
ItemAction(
stringResource(MR.strings.chat_list_all),
painterResource(MR.images.ic_menu),
onClick = {
chatModel.activeChatTagFilter.value = null
onCloseMenuAction.value = {
searchText.value = TextFieldValue()
chatModel.activeChatTagFilter.value = null
onCloseMenuAction.value = {}
}
showMenu.value = false
}
)
}
PresetTagKind.entries.forEach { tag ->
if ((presetTags[tag] ?: 0) > 0) {
ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu)
if ((presetTags[tag] ?: 0) > 0 && presetCanBeCollapsed(tag)) {
ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction)
}
}
}
@@ -1179,14 +1167,19 @@ private fun CollapsedTagsFilterView() {
fun ItemPresetFilterAction(
presetTag: PresetTagKind,
active: Boolean,
showMenu: MutableState<Boolean>
showMenu: MutableState<Boolean>,
onCloseMenuAction: MutableState<(() -> Unit)>
) {
val (icon, text) = presetTagLabel(presetTag, active)
ItemAction(
stringResource(text),
painterResource(icon),
color = if (active) MaterialTheme.colors.primary else Color.Unspecified,
onClick = {
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag)
onCloseMenuAction.value = {
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag)
onCloseMenuAction.value = {}
}
showMenu.value = false
}
)
@@ -1205,26 +1198,18 @@ fun filteredChats(
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty())
chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD && filtered(chat, activeFilter) }
chats.filter { chat -> chat.id == chatModel.chatId.value || (!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat, activeFilter)) }
else {
chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> chatContactType(chat) != ContactType.CARD && !chat.chatInfo.chatDeleted && (
if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat, activeFilter)
} else {
cInfo.anyNameContains(s)
})
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat, activeFilter) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
cInfo.anyNameContains(s)
chat.id == chatModel.chatId.value ||
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> !cInfo.contact.chatDeleted && !chat.chatInfo.contactCard && cInfo.anyNameContains(s)
is ChatInfo.Group -> cInfo.anyNameContains(s)
is ChatInfo.Local -> cInfo.anyNameContains(s)
is ChatInfo.ContactRequest -> cInfo.anyNameContains(s)
is ChatInfo.ContactConnection -> cInfo.contactConnection.localAlias.lowercase().contains(s)
is ChatInfo.InvalidJSON -> false
}
is ChatInfo.Local -> s.isEmpty() || cInfo.anyNameContains(s)
is ChatInfo.ContactRequest -> s.isEmpty() || cInfo.anyNameContains(s)
is ChatInfo.ContactConnection -> (s.isNotEmpty() && cInfo.anyNameContains(s)) || (s.isEmpty() && chat.id == chatModel.chatId.value)
is ChatInfo.InvalidJSON -> chat.id == chatModel.chatId.value
}
}
}
}
@@ -1232,14 +1217,15 @@ fun filteredChats(
private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean =
when (activeFilter) {
is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo)
is ActiveFilter.PresetTag -> presetTagMatchesChat(activeFilter.tag, chat.chatInfo, chat.chatStats)
is ActiveFilter.UserTag -> chat.chatInfo.chatTags?.contains(activeFilter.tag.chatTagId) ?: false
is ActiveFilter.Unread -> chat.chatStats.unreadChat || chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0
else -> true
}
fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean =
fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo, chatStats: Chat.ChatStats): Boolean =
when (tag) {
PresetTagKind.GROUP_REPORTS -> chatStats.reportsCount > 0
PresetTagKind.FAVORITES -> chatInfo.chatSettings?.favorite == true
PresetTagKind.CONTACTS -> when (chatInfo) {
is ChatInfo.Direct -> !(chatInfo.contact.activeConn == null && chatInfo.contact.profile.contactLink != null && chatInfo.contact.active) && !chatInfo.contact.chatDeleted
@@ -1256,16 +1242,27 @@ fun presetTagMatchesChat(tag: PresetTagKind, chatInfo: ChatInfo): Boolean =
is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business
else -> false
}
PresetTagKind.NOTES -> when (chatInfo) {
is ChatInfo.Local -> !chatInfo.noteFolder.chatDeleted
else -> false
}
}
private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair<ImageResource, StringResource> =
when (tag) {
PresetTagKind.GROUP_REPORTS -> (if (active) MR.images.ic_flag_filled else MR.images.ic_flag) to MR.strings.chat_list_group_reports
PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites
PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts
PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups
PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses
PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes
}
private fun presetCanBeCollapsed(tag: PresetTagKind): Boolean = when (tag) {
PresetTagKind.GROUP_REPORTS -> false
else -> true
}
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } }
}
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent
@@ -21,11 +22,13 @@ 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.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
@@ -44,7 +47,8 @@ fun ChatPreviewView(
disabled: Boolean,
linkMode: SimplexLinkMode,
inProgress: Boolean,
progressByTimeout: Boolean
progressByTimeout: Boolean,
defaultClickAction: () -> Unit
) {
val cInfo = chat.chatInfo
@@ -174,13 +178,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, chat.chatInfo) 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 +216,7 @@ fun ChatPreviewView(
),
inlineContent = inlineTextContent,
modifier = Modifier.fillMaxWidth(),
prefix = prefix
)
}
} else {
@@ -236,7 +251,38 @@ fun ChatPreviewView(
val uriHandler = LocalUriHandler.current
when (mc) {
is MsgContent.MCLink -> SmallContentPreview {
IconButton({ uriHandler.openUriCatching(mc.preview.uri) }, Modifier.desktopPointerHoverIconHand()) {
val linkClicksEnabled = remember { appPrefs.privacyChatListOpenLinks.state }.value != PrivacyChatListOpenLinksMode.NO
IconButton({
when (appPrefs.privacyChatListOpenLinks.get()) {
PrivacyChatListOpenLinksMode.YES -> uriHandler.openUriCatching(mc.preview.uri)
PrivacyChatListOpenLinksMode.NO -> defaultClickAction()
PrivacyChatListOpenLinksMode.ASK -> AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.privacy_chat_list_open_web_link_question),
text = mc.preview.uri,
buttons = {
Column {
if (chatModel.chatId.value != chat.id) {
SectionItemView({
AlertManager.shared.hideAlert()
defaultClickAction()
}) {
Text(stringResource(MR.strings.open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
uriHandler.openUriCatching(mc.preview.uri)
}
) {
Text(stringResource(MR.strings.privacy_chat_list_open_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
},
if (linkClicksEnabled) Modifier.desktopPointerHoverIconHand() else Modifier,
) {
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
}
Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) {
@@ -310,6 +356,8 @@ fun ChatPreviewView(
} else if (cInfo is ChatInfo.Group) {
if (progressByTimeout) {
progressView()
} else if (chat.chatStats.reportsCount > 0) {
GroupReportsIcon()
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
@@ -457,6 +505,18 @@ fun IncognitoIcon(incognito: Boolean) {
}
}
@Composable
fun GroupReportsIcon() {
Icon(
painterResource(MR.images.ic_flag),
contentDescription = null,
tint = MaterialTheme.colors.error,
modifier = Modifier
.size(21.sp.toDp())
.offset(x = 2.sp.toDp())
)
}
@Composable
private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
return if (groupInfo.membership.memberIncognito)
@@ -501,6 +561,6 @@ private data class ActiveVoicePreview(
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false)
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {})
}
}
@@ -5,8 +5,7 @@ import SectionDivider
import SectionItemView
import TextIconSpaced
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
@@ -33,6 +32,7 @@ import chat.simplex.common.model.ChatController.apiDeleteChatTag
import chat.simplex.common.model.ChatController.apiSetChatTags
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
@@ -44,12 +44,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: MutableState<Boolean> = remember { mutableStateOf(false) }) {
if (remember { editMode }.value) {
BackHandler {
editMode.value = false
}
}
fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, reorderMode: Boolean) {
val userTags = remember { chatModel.userTags }
val oneHandUI = remember { appPrefs.oneHandUI.state }
val listState = LocalAppBarHandler.current?.listState ?: rememberLazyListState()
@@ -77,12 +72,12 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
val topPaddingToContent = topPaddingToContent(false)
LazyColumnWithScrollBar(
modifier = if (editMode.value) Modifier.dragContainer(dragDropState) else Modifier,
modifier = if (reorderMode) Modifier.dragContainer(dragDropState) else Modifier,
state = listState,
contentPadding = PaddingValues(
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent,
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
),
state = listState,
verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top,
) {
@Composable fun CreateList() {
@@ -97,7 +92,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
}
}
if (oneHandUI.value && !editMode.value) {
if (oneHandUI.value && !reorderMode) {
item {
CreateList()
}
@@ -111,15 +106,14 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
backgroundColor = if (isDragging) colors.surface else Color.Unspecified
) {
Column {
val showMenu = remember { mutableStateOf(false) }
val selected = chatTagIds.value.contains(tag.chatTagId)
Row(
Modifier
.fillMaxWidth()
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
.combinedClickable(
enabled = !saving.value,
.clickable(
enabled = !saving.value && !reorderMode,
onClick = {
if (chat == null) {
ModalManager.start.showModalCloseable { close ->
@@ -139,13 +133,7 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
})
}
},
onLongClick = if (editMode.value) null else {
{ showMenu.value = true }
},
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current
)
.onRightClick { showMenu.value = true }
.padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)),
verticalAlignment = Alignment.CenterVertically
) {
@@ -163,21 +151,17 @@ fun TagListView(rhId: Long?, chat: Chat? = null, close: () -> Unit, editMode: Mu
if (selected) {
Spacer(Modifier.weight(1f))
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
} else if (editMode.value) {
} else if (reorderMode) {
Spacer(Modifier.weight(1f))
Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
}
DefaultDropdownMenu(showMenu, dropdownMenuItems = {
EditTagAction(rhId, tag, showMenu)
DeleteTagAction(rhId, tag, showMenu, saving)
})
}
SectionDivider()
}
}
}
}
if (!oneHandUI.value && !editMode.value) {
if (!oneHandUI.value && !reorderMode) {
item {
CreateList()
}
@@ -279,7 +263,7 @@ fun ModalData.TagListEditor(
SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) {
Text(
generalGetString(if (chat != null) MR.strings.add_to_list else if (tagId == null) MR.strings.create_list else MR.strings.save_list),
generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list),
color = if (disabled) colors.secondary else colors.primary
)
}
@@ -309,6 +293,15 @@ fun ModalData.TagListEditor(
}
}
@Composable
fun TagsDropdownMenu(rhId: Long?, tag: ChatTag, showMenu: MutableState<Boolean>, saving: MutableState<Boolean>) {
DefaultDropdownMenu(showMenu, dropdownMenuItems = {
EditTagAction(rhId, tag, showMenu)
DeleteTagAction(rhId, tag, showMenu, saving)
ChangeOrderTagAction(rhId, showMenu)
})
}
@Composable
private fun DeleteTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Boolean>, saving: MutableState<Boolean>) {
ItemAction(
@@ -343,6 +336,21 @@ private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Bool
)
}
@Composable
private fun ChangeOrderTagAction(rhId: Long?, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.change_order_chat_list_menu_action),
painterResource(MR.images.ic_drag_handle),
onClick = {
showMenu.value = false
ModalManager.start.showModalCloseable { close ->
TagListView(rhId = rhId, close = close, reorderMode = true)
}
},
color = MenuTextColor
)
}
@Composable
expect fun ChatTagInput(name: MutableState<String>, showError: State<Boolean>, emoji: MutableState<String?>)
@@ -21,7 +21,9 @@ fun onRequestAccepted(chat: Chat) {
if (chatInfo is ChatInfo.Direct) {
ModalManager.start.closeModals()
if (chatInfo.contact.sndReady) {
openLoadedChat(chat)
withApi {
openLoadedChat(chat)
}
}
}
}
@@ -21,6 +21,7 @@ import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
@@ -528,9 +529,14 @@ fun deleteChatDatabaseFilesAndState() {
// Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself
chatModel.chatId.value = null
chatModel.chatItems.clearAndNotify()
withLongRunningApi {
withChats {
chatItems.clearAndNotify()
chats.clear()
popChatCollector.clear()
}
withReportsChatsIfOpen {
chatItems.clearAndNotify()
chats.clear()
popChatCollector.clear()
}
@@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
@@ -16,6 +15,7 @@ fun DefaultDropdownMenu(
showMenu: MutableState<Boolean>,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
onClosed: State<() -> Unit> = remember { mutableStateOf({}) },
dropdownMenuItems: (@Composable () -> Unit)?
) {
MaterialTheme(
@@ -31,6 +31,11 @@ fun DefaultDropdownMenu(
offset = offset,
) {
dropdownMenuItems?.invoke()
DisposableEffect(Unit) {
onDispose {
onClosed.value()
}
}
}
}
}
@@ -35,7 +35,8 @@ fun DefaultAppBar(
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
val modifier = if (!showSearch) {
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
} else Modifier.imePadding()
} else if (!onTop) Modifier.imePadding()
else Modifier
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
val prefAlpha = remember { appPrefs.inAppBarsAlpha.state }
@@ -51,7 +51,7 @@ fun authenticateWithPasscode(
close()
completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled)))
}
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
LocalAuthView(ChatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && ChatController.appPrefs.selfDestruct.get()) {
close()
completed(it)
@@ -77,8 +77,19 @@ class ModalData(val keyboardCoversBar: Boolean = true) {
val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar)
}
enum class ModalViewId {
GROUP_REPORTS
}
class ModalManager(private val placement: ModalPlacement? = null) {
private val modalViews = arrayListOf<Triple<Boolean, ModalData, (@Composable ModalData.(close: () -> Unit) -> Unit)>>()
data class ModalViewHolder(
val id: ModalViewId?,
val animated: Boolean,
val data: ModalData,
val modal: @Composable ModalData.(close: () -> Unit) -> Unit
)
private val modalViews = arrayListOf<ModalViewHolder>()
private val _modalCount = mutableStateOf(0)
val modalCount: State<Int> = _modalCount
private val toRemove = mutableSetOf<Int>()
@@ -88,19 +99,23 @@ class ModalManager(private val placement: ModalPlacement? = null) {
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal { close ->
fun hasModalOpen(id: ModalViewId): Boolean = modalViews.any { it.id == id }
fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id
fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
showCustomModal(id = id) { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() })
}
}
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
showCustomModal { close ->
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
showCustomModal(id = id) { close ->
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) })
}
}
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showCustomModal")
val data = ModalData(keyboardCoversBar = keyboardCoversBar)
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
@@ -111,7 +126,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
// Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0)
// to prevent unneeded animation on different situations
val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START)
modalViews.add(Triple(anim, data, modal))
modalViews.add(ModalViewHolder(id, anim, data, modal))
_modalCount.value = modalViews.size - toRemove.size
if (placement == ModalPlacement.CENTER) {
@@ -139,7 +154,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex)
else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) }
}
_modalCount.value = modalViews.size - toRemove.size
@@ -161,10 +176,10 @@ class ModalManager(private val placement: ModalPlacement? = null) {
@Composable
fun showInView() {
// Without animation
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
if (modalCount.value > 0 && modalViews.lastOrNull()?.animated == false) {
modalViews.lastOrNull()?.let {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) {
it.modal(it.data, ::closeModal)
}
}
return
@@ -179,8 +194,8 @@ class ModalManager(private val placement: ModalPlacement? = null) {
}
) {
modalViews.getOrNull(it - 1)?.let {
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
it.third(it.second, ::closeModal)
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) {
it.modal(it.data, ::closeModal)
}
}
// This is needed because if we delete from modalViews immediately on request, animation will be bad
@@ -10,6 +10,7 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.ThemeModeOverrides
import chat.simplex.common.ui.theme.ThemeOverrides
import chat.simplex.common.views.chatlist.connectIfOpenedViaUri
import chat.simplex.res.MR
@@ -316,8 +317,33 @@ fun removeWallpaperFile(fileName: String? = null) {
WallpaperType.cachedImages.remove(fileName)
}
fun <T> createTmpFileAndDelete(onCreated: (File) -> T): T {
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
fun removeWallpaperFilesFromTheme(theme: ThemeModeOverrides?) {
if (theme != null) {
removeWallpaperFile(theme.light?.wallpaper?.imageFile)
removeWallpaperFile(theme.dark?.wallpaper?.imageFile)
}
}
fun removeWallpaperFilesFromChat(chat: Chat) {
if (chat.chatInfo is ChatInfo.Direct) {
removeWallpaperFilesFromTheme(chat.chatInfo.contact.uiThemes)
} else if (chat.chatInfo is ChatInfo.Group) {
removeWallpaperFilesFromTheme(chat.chatInfo.groupInfo.uiThemes)
}
}
fun removeWallpaperFilesFromAllChats(user: User) {
// Currently, only removing everything from currently active user is supported. Inactive users are TODO
if (user.userId == chatModel.currentUser.value?.userId) {
chatModel.chats.value.forEach {
removeWallpaperFilesFromChat(it)
}
}
}
fun <T> createTmpFileAndDelete(dir: File = tmpDir, onCreated: (File) -> T): T {
val tmpFile = File(dir, UUID.randomUUID().toString())
tmpFile.parentFile.mkdirs()
tmpFile.deleteOnExit()
ChatModel.filesToDelete.add(tmpFile)
try {
@@ -44,8 +44,8 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c
if (groupInfo != null) {
withChats {
updateGroup(rhId = rhId, groupInfo)
chatModel.chatItems.clearAndNotify()
chatModel.chatItemStatuses.clear()
chatItems.clearAndNotify()
chatItemStatuses.clear()
chatModel.chatId.value = groupInfo.id
}
setGroupMembers(rhId, groupInfo, chatModel)
@@ -16,6 +16,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.setConditionsNotified
import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo
@@ -766,7 +768,9 @@ private val versionDescriptions: List<VersionDescription> = listOf(
private val lastVersion = versionDescriptions.last().version
fun setLastVersionDefault(m: ChatModel) {
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
if (appPrefs.whatsNewVersion.get() != lastVersion) {
appPrefs.whatsNewVersion.set(lastVersion)
}
}
fun shouldShowWhatsNew(m: ChatModel): Boolean {
@@ -63,6 +63,9 @@ fun PrivacySettingsView(
SectionView(stringResource(MR.strings.settings_section_title_chats)) {
SettingsPreferenceItem(painterResource(MR.images.ic_travel_explore), stringResource(MR.strings.send_link_previews), chatModel.controller.appPrefs.privacyLinkPreviews)
ChatListLinksOptions(appPrefs.privacyChatListOpenLinks.state, onSelected = {
appPrefs.privacyChatListOpenLinks.set(it)
})
SettingsPreferenceItem(
painterResource(MR.images.ic_chat_bubble),
stringResource(MR.strings.privacy_show_last_messages),
@@ -199,6 +202,26 @@ fun PrivacySettingsView(
}
}
@Composable
private fun ChatListLinksOptions(state: State<PrivacyChatListOpenLinksMode>, onSelected: (PrivacyChatListOpenLinksMode) -> Unit) {
val values = remember {
PrivacyChatListOpenLinksMode.entries.map {
when (it) {
PrivacyChatListOpenLinksMode.YES -> it to generalGetString(MR.strings.privacy_chat_list_open_links_yes)
PrivacyChatListOpenLinksMode.NO -> it to generalGetString(MR.strings.privacy_chat_list_open_links_no)
PrivacyChatListOpenLinksMode.ASK -> it to generalGetString(MR.strings.privacy_chat_list_open_links_ask)
}
}
}
ExposedDropDownSettingRow(
generalGetString(MR.strings.privacy_chat_list_open_links),
values,
state,
icon = painterResource(MR.images.ic_open_in_new),
onSelected = onSelected
)
}
@Composable
private fun SimpleXLinkOptions(simplexLinkModeState: State<SimplexLinkMode>, onSelected: (SimplexLinkMode) -> Unit) {
val modeValues = listOf(SimplexLinkMode.DESCRIPTION, SimplexLinkMode.FULL)
@@ -422,7 +445,7 @@ fun SimplexLockView(
}
LAMode.PASSCODE -> {
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
SetAppPasscodeView(
submit = {
laLockDelay.set(30)
@@ -466,7 +489,7 @@ fun SimplexLockView(
when (laResult) {
LAResult.Success -> {
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
SetAppPasscodeView(
reason = generalGetString(MR.strings.la_app_passcode),
submit = {
@@ -490,7 +513,7 @@ fun SimplexLockView(
when (laResult) {
LAResult.Success -> {
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
SetAppPasscodeView(
passcodeKeychain = ksSelfDestructPassword,
prohibitedPasscodeKeychain = ksAppPassword,
@@ -525,7 +548,7 @@ fun SimplexLockView(
}
LAMode.PASSCODE -> {
ModalManager.fullscreen.showCustomModal { close ->
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
SetAppPasscodeView(
submit = {
laLockDelay.set(30)
@@ -638,7 +661,7 @@ private fun EnableSelfDestruct(
selfDestruct: SharedPreference<Boolean>,
close: () -> Unit
) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
SetAppPasscodeView(
passcodeKeychain = ksSelfDestructPassword, prohibitedPasscodeKeychain = ksAppPassword, title = generalGetString(MR.strings.set_passcode), reason = generalGetString(MR.strings.enabled_self_destruct_passcode),
submit = {
@@ -444,17 +444,19 @@ fun doWithAuth(title: String, desc: String, block: () -> Unit) {
runAuth(title, desc, onFinishAuth)
}
}
Box(
Modifier.fillMaxSize().background(MaterialTheme.colors.background),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(MR.strings.auth_unlock),
icon = painterResource(MR.images.ic_lock),
click = {
runAuth(title, desc, onFinishAuth)
}
)
Surface(color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
Box(
Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
SimpleButton(
stringResource(MR.strings.auth_unlock),
icon = painterResource(MR.images.ic_lock),
click = {
runAuth(title, desc, onFinishAuth)
}
)
}
}
}
}
@@ -347,6 +347,7 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List<User>, de
try {
when {
user.activeUser -> {
removeWallpaperFilesFromAllChats(user)
val newActive = users.firstOrNull { u -> !u.activeUser && !u.hidden }
if (newActive != null) {
m.controller.changeActiveUser_(user.remoteHostId, newActive.userId, null)
@@ -366,6 +367,7 @@ private suspend fun doRemoveUser(m: ChatModel, user: User, users: List<User>, de
m.controller.apiDeleteUser(user, delSMPQueues, viewPwd)
}
}
removeWallpaperFilesFromTheme(user.uiThemes)
m.removeUser(user)
ntfManager.cancelNotificationsForUser(user.userId)
} catch (e: Exception) {
@@ -37,6 +37,10 @@
<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="report_item_archived_by">archived report by %s</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 +98,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>
@@ -121,6 +132,8 @@
<string name="for_chat_profile">For chat profile %s:</string>
<string name="errors_in_servers_configuration">Errors in servers configuration.</string>
<string name="error_accepting_operator_conditions">Error accepting conditions</string>
<string name="blocking_reason_spam">Spam</string>
<string name="blocking_reason_content">Content violates conditions of use</string>
<!-- API Error Responses - SimpleXAPI.kt -->
<string name="connection_timeout">Connection timeout</string>
@@ -139,6 +152,7 @@
<string name="error_sending_message">Error sending message</string>
<string name="error_forwarding_messages">Error forwarding messages</string>
<string name="error_creating_message">Error creating message</string>
<string name="error_creating_report">Error creating report</string>
<string name="error_loading_details">Error loading details</string>
<string name="error_adding_members">Error adding member(s)</string>
<string name="error_joining_group">Error joining group</string>
@@ -156,6 +170,8 @@
<string name="please_check_correct_link_and_maybe_ask_for_a_new_one">Please check that you used the correct link or ask your contact to send you another one.</string>
<string name="connection_error_auth">Connection error (AUTH)</string>
<string name="connection_error_auth_desc">Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection.</string>
<string name="connection_error_blocked">Connection blocked</string>
<string name="connection_error_blocked_desc">Connection is blocked by server operator:\n%1$s.</string>
<string name="connection_error_quota">Undelivered messages</string>
<string name="connection_error_quota_desc">The connection reached the limit of undelivered messages, your contact may be offline.</string>
<string name="error_accepting_contact_request">Error accepting contact request</string>
@@ -294,6 +310,9 @@
<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>
<!-- CIStatus errors -->
<string name="ci_status_other_error">Error: %1$s</string>
@@ -308,6 +327,7 @@
<!-- CIFileStatus errors -->
<string name="file_error_auth">Wrong key or unknown file chunk address - most likely file is deleted.</string>
<string name="file_error_blocked">File is blocked by server operator:\n%1$s.</string>
<string name="file_error_no_file">File not found - most likely file was deleted or cancelled.</string>
<string name="file_error_relay">File server error: %1$s</string>
@@ -319,6 +339,9 @@
<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="archive_report">Archive report</string>
<string name="delete_report">Delete report</string>
<string name="sent_message">Sent message</string>
<string name="received_message">Received message</string>
<string name="edit_history">History</string>
@@ -336,6 +359,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>
@@ -421,8 +445,14 @@
<string name="chat_list_contacts">Contacts</string>
<string name="chat_list_groups">Groups</string>
<string name="chat_list_businesses">Businesses</string>
<string name="chat_list_notes">Notes</string>
<string name="chat_list_group_reports">Reports</string>
<string name="chat_list_all">All</string>
<string name="chat_list_add_list">Add list</string>
<string name="group_reports_active_one">1 report</string>
<string name="group_reports_active">%d reports</string>
<string name="group_reports_member_reports">Member reports</string>
<string name="group_reports_archived_member_reports">Archived member reports</string>
<!-- ShareListView.kt -->
<string name="share_message">Share message…</string>
@@ -461,6 +491,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>
@@ -644,6 +679,7 @@
<!-- Tags - ChatListNavLinkView.kt -->
<string name="create_list">Create list</string>
<string name="add_to_list">Add to list</string>
<string name="change_list">Change list</string>
<string name="save_list">Save list</string>
<string name="list_name_field_placeholder">List name...</string>
<string name="duplicated_list_error">List name and emoji should be different for all lists.</string>
@@ -651,6 +687,7 @@
<string name="delete_chat_list_question">Delete list?</string>
<string name="delete_chat_list_warning">All chats will be removed from the list %s, and the list deleted</string>
<string name="edit_chat_list_menu_action">Edit</string>
<string name="change_order_chat_list_menu_action">Change order</string>
<!-- Pending contact connection alert dialogues -->
<string name="you_invited_a_contact">You invited a contact</string>
@@ -939,6 +976,7 @@
<string name="show_slow_api_calls">Show slow API calls</string>
<string name="shutdown_alert_question">Shutdown?</string>
<string name="shutdown_alert_desc">Notifications will stop working until you re-launch the app</string>
<string name="prefs_error_saving_settings">Error saving settings</string>
<!-- Address Items - UserAddressView.kt -->
<string name="create_address">Create address</string>
@@ -1269,6 +1307,12 @@
<string name="privacy_media_blur_radius_soft">Soft</string>
<string name="privacy_media_blur_radius_medium">Medium</string>
<string name="privacy_media_blur_radius_strong">Strong</string>
<string name="privacy_chat_list_open_links">Open links from chat list</string>
<string name="privacy_chat_list_open_links_yes">Yes</string>
<string name="privacy_chat_list_open_links_no">No</string>
<string name="privacy_chat_list_open_links_ask">Ask</string>
<string name="privacy_chat_list_open_web_link_question">Open web link?</string>
<string name="privacy_chat_list_open_web_link">Open link</string>
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
@@ -1552,6 +1596,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>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M204-123.5v-672h337l19.13 85.5H796v363H545.5l-19-84.5h-265v308H204Z"/></svg>

After

Width:  |  Height:  |  Size: 192 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M165-170q-30.94 0-52.97-22.03Q90-214.06 90-245v-470q0-30.94 22.03-52.97Q134.06-790 165-790h209q15.14 0 28.87 5.74Q416.59-778.52 427-768l53 53h315q30.94 0 52.97 22.03Q870-670.94 870-640v395q0 30.94-22.03 52.97Q825.94-170 795-170H165Zm0-75h630v-395H449l-75-75H165v470Zm0 0v-470 470Z"/></svg>

After

Width:  |  Height:  |  Size: 386 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M165-170q-30.94 0-52.97-22.03Q90-214.06 90-245v-470q0-30.94 22.03-52.97Q134.06-790 165-790h209q15.14 0 28.87 5.74Q416.59-778.52 427-768l53 53h315q30.94 0 52.97 22.03Q870-670.94 870-640v395q0 30.94-22.03 52.97Q825.94-170 795-170H165Z"/></svg>

After

Width:  |  Height:  |  Size: 338 B

@@ -14,6 +14,8 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.withChats
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme
@@ -55,8 +57,15 @@ fun showApp() {
// Better to not close fullscreen since it can contain passcode
} else {
// The last possible cause that can be closed
chatModel.chatId.value = null
chatModel.chatItems.clearAndNotify()
withApi {
withChats {
chatModel.chatId.value = null
chatItems.clearAndNotify()
}
withReportsChatsIfOpen {
chatItems.clearAndNotify()
}
}
}
chatModel.activeCall.value?.let {
withBGApi {
@@ -17,6 +17,8 @@ actual val wallpapersDir: File = File(dataDir.absolutePath + File.separator + "s
actual val coreTmpDir: File = File(dataDir.absolutePath + File.separator + "tmp")
actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "simplex_v1"
actual val preferencesDir = File(desktopPlatform.configPath).also { it.parentFile.mkdirs() }
actual val preferencesTmpDir = File(desktopPlatform.configPath, "tmp")
.also { it.deleteRecursively() }
actual val chatDatabaseFileName: String = "simplex_v1_chat.db"
actual val agentDatabaseFileName: String = "simplex_v1_agent.db"
@@ -9,15 +9,16 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.simplexWindowState
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import com.jthemedetecor.OsThemeDetector
import com.russhwolf.settings.*
import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource
import dev.icerock.moko.resources.desc.desc
import kotlinx.coroutines.*
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.*
import java.util.concurrent.Executors
@Composable
actual fun font(name: String, res: String, weight: FontWeight, style: FontStyle): Font =
@@ -37,10 +38,8 @@ catch (e: Exception) {
private val settingsFile =
File(desktopPlatform.configPath + File.separator + "settings.properties")
.also { it.parentFile.mkdirs() }
private val settingsThemesFile =
File(desktopPlatform.configPath + File.separator + "themes.properties")
.also { it.parentFile.mkdirs() }
private val settingsProps =
Properties()
@@ -61,11 +60,35 @@ private val settingsThemesProps =
Properties()
.also { props -> try { settingsThemesFile.reader().use { props.load(it) } } catch (e: Exception) { /**/ } }
private val settingsWriterThread = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
actual val settings: Settings = PropertiesSettings(settingsProps) { CoroutineScope(settingsWriterThread).launch { settingsFile.writer().use { settingsProps.store(it, "") } } }
actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) { CoroutineScope(settingsWriterThread).launch { settingsThemesFile.writer().use { settingsThemesProps.store(it, "") } } }
private const val lock = "settingsSaver"
actual val settings: Settings = PropertiesSettings(settingsProps) {
synchronized(lock) {
try {
createTmpFileAndDelete(preferencesTmpDir) { tmpFile ->
tmpFile.writer().use { settingsProps.store(it, "") }
settingsFile.parentFile.mkdirs()
Files.move(tmpFile.toPath(), settingsFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.prefs_error_saving_settings), e.stackTraceToString())
throw e
}
}
}
actual val settingsThemes: Settings = PropertiesSettings(settingsThemesProps) {
synchronized(lock) {
try {
createTmpFileAndDelete(preferencesTmpDir) { tmpFile ->
tmpFile.writer().use { settingsThemesProps.store(it, "") }
settingsThemesFile.parentFile.mkdirs()
Files.move(tmpFile.toPath(), settingsThemesFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.prefs_error_saving_settings), e.stackTraceToString())
throw e
}
}
}
actual fun windowOrientation(): WindowOrientation =
if (simplexWindowState.windowState.size.width > simplexWindowState.windowState.size.height) {
@@ -36,6 +36,7 @@ actual fun LazyColumnWithScrollBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
fillMaxSize: Boolean,
content: LazyListScope.() -> Unit
@@ -93,7 +94,7 @@ actual fun LazyColumnWithScrollBar(
val modifier = if (fillMaxSize) Modifier.fillMaxSize().then(modifier) else modifier
Box(Modifier.copyViewToAppBar(remember { appPrefs.appearanceBarsBlurRadius.state }.value, LocalAppBarHandler.current?.graphicsLayer).nestedScroll(connection)) {
LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar)
}
}
@@ -108,6 +109,7 @@ actual fun LazyColumnWithScrollBarNoAppBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
additionalBarOffset: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
content: LazyListScope.() -> Unit
) {
@@ -135,7 +137,7 @@ actual fun LazyColumnWithScrollBarNoAppBar(
val scrollBarDraggingState = remember { mutableStateOf(false) }
Box {
LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, chatBottomBar)
ScrollBar(reverseLayout, state, scrollBarAlpha, scrollJob, scrollBarDraggingState, additionalBarOffset, additionalTopBar, chatBottomBar)
}
}
@@ -147,11 +149,13 @@ private fun ScrollBar(
scrollJob: MutableState<Job>,
scrollBarDraggingState: MutableState<Boolean>,
additionalBarHeight: State<Dp>?,
additionalTopBar: State<Boolean>,
chatBottomBar: State<Boolean>,
) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val topBarPadding = if (additionalTopBar.value) AppBarHeight * fontSizeSqrtMultiplier else 0.dp
val padding = if (additionalBarHeight != null) {
PaddingValues(top = if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value)
PaddingValues(top = topBarPadding + if (oneHandUI.value && chatBottomBar.value) 0.dp else AppBarHeight * fontSizeSqrtMultiplier, bottom = additionalBarHeight.value)
} else if (reverseLayout) {
PaddingValues(bottom = AppBarHeight * fontSizeSqrtMultiplier)
} else {
@@ -21,6 +21,12 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@OptIn(ExperimentalLayoutApi::class)
@Composable
actual fun TagsRow(content: @Composable() (() -> Unit)) {
FlowRow(modifier = Modifier.padding(horizontal = 14.dp)) { content() }
}
@Composable
actual fun ActiveCallInteractiveArea(call: Call) {
val showMenu = remember { mutableStateOf(false) }
+2 -2
View File
@@ -31,9 +31,9 @@ main = do
welcomeGetOpts :: IO ChatOpts
welcomeGetOpts = do
appDir <- getAppUserDataDirectory "simplex"
opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getChatOpts appDir "simplex_bot"
opts@ChatOpts {coreOptions} <- getChatOpts appDir "simplex_bot"
putStrLn $ "SimpleX Chat Bot v" ++ versionNumber
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
printDbOpts coreOptions
pure opts
welcomeMessage :: Text
+2 -2
View File
@@ -25,7 +25,7 @@ welcomeMessage = "Hello! I am a simple squaring bot.\nIf you send me a number, I
welcomeGetOpts :: IO ChatOpts
welcomeGetOpts = do
appDir <- getAppUserDataDirectory "simplex"
opts@ChatOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getChatOpts appDir "simplex_bot"
opts@ChatOpts {coreOptions} <- getChatOpts appDir "simplex_bot"
putStrLn $ "SimpleX Chat Bot v" ++ versionNumber
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
printDbOpts coreOptions
pure opts
@@ -27,9 +27,9 @@ import System.Directory (getAppUserDataDirectory)
welcomeGetOpts :: IO BroadcastBotOpts
welcomeGetOpts = do
appDir <- getAppUserDataDirectory "simplex"
opts@BroadcastBotOpts {coreOptions = CoreChatOpts {dbFilePrefix}} <- getBroadcastBotOpts appDir "simplex_status_bot"
opts@BroadcastBotOpts {coreOptions} <- getBroadcastBotOpts appDir "simplex_status_bot"
putStrLn $ "SimpleX Chat Bot v" ++ versionNumber
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
printDbOpts coreOptions
pure opts
broadcastBot :: BroadcastBotOpts -> User -> ChatController -> IO ()
@@ -27,8 +27,8 @@ defaultProhibitedMessage :: [KnownContact] -> Text
defaultProhibitedMessage ps = "Sorry, only these users can broadcast messages: " <> knownContactNames ps <> ". Your message is deleted."
broadcastBotOpts :: FilePath -> FilePath -> Parser BroadcastBotOpts
broadcastBotOpts appDir defaultDbFileName = do
coreOptions <- coreChatOptsP appDir defaultDbFileName
broadcastBotOpts appDir defaultDbName = do
coreOptions <- coreChatOptsP appDir defaultDbName
publishers <-
option
parseKnownContacts
@@ -61,10 +61,10 @@ broadcastBotOpts appDir defaultDbFileName = do
}
getBroadcastBotOpts :: FilePath -> FilePath -> IO BroadcastBotOpts
getBroadcastBotOpts appDir defaultDbFileName =
getBroadcastBotOpts appDir defaultDbName =
execParser $
info
(helper <*> versionOption <*> broadcastBotOpts appDir defaultDbFileName)
(helper <*> versionOption <*> broadcastBotOpts appDir defaultDbName)
(header versionStr <> fullDesc <> progDesc "Start chat bot with DB_FILE file and use SERVER as SMP server")
where
versionStr = versionString versionNumber
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff. Show more