mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-23 15:03:16 +00:00
Merge branch 'master' into av/ios-infinity-scroll
This commit is contained in:
commit
bf54e22bee
292 files changed
+7448
-3793
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) } }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)!
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
+2
@@ -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
|
||||
) {
|
||||
|
||||
+11
-3
@@ -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
|
||||
}
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+403
-186
@@ -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() {}
|
||||
|
||||
|
||||
+212
-22
@@ -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(),
|
||||
|
||||
+19
-9
@@ -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) =
|
||||
|
||||
+2
@@ -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
|
||||
)
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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(
|
||||
|
||||
+4
-3
@@ -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 ->
|
||||
|
||||
+1
@@ -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.*
|
||||
|
||||
+27
-19
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -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
|
||||
|
||||
+375
-125
File diff suppressed because it is too large.
Load diff
+214
-149
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -31,6 +32,7 @@ fun ContextItemView(
|
||||
contextIcon: Painter,
|
||||
showSender: Boolean = true,
|
||||
chatType: ChatType,
|
||||
contextIconColor: Color = MaterialTheme.colors.secondary,
|
||||
cancelContextItem: () -> Unit,
|
||||
) {
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
@@ -85,7 +87,6 @@ fun ContextItemView(
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(if (sent) sentColor else receivedColor),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -103,8 +104,8 @@ fun ContextItemView(
|
||||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
contentDescription = stringResource(MR.strings.icon_descr_context),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
)
|
||||
tint = contextIconColor,
|
||||
)
|
||||
|
||||
if (contextItems.count() == 1) {
|
||||
val contextItem = contextItems[0]
|
||||
|
||||
+9
-10
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -74,7 +74,7 @@ fun SendMsgView(
|
||||
}
|
||||
}
|
||||
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem)
|
||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||
val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() ||
|
||||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||
@@ -125,6 +125,9 @@ fun SendMsgView(
|
||||
}
|
||||
when {
|
||||
progressByTimeout -> ProgressIndicator()
|
||||
cs.contextItem is ComposeContextItem.ReportedItem -> {
|
||||
SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage)
|
||||
}
|
||||
showVoiceButton && sendMsgEnabled -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||
|
||||
+7
-3
@@ -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),
|
||||
|
||||
+32
-6
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+36
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
@@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
|
||||
updateGroup(rhId, g)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
withChats {
|
||||
updateGroup(rhId, g)
|
||||
}
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
|
||||
+1
@@ -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.*
|
||||
|
||||
+106
@@ -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))
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+3
-2
@@ -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)) {
|
||||
|
||||
+38
-16
@@ -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>,
|
||||
|
||||
+4
-16
@@ -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
|
||||
|
||||
+4
-16
@@ -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)
|
||||
|
||||
+4
-16
@@ -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 ->
|
||||
|
||||
+104
-17
@@ -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)
|
||||
|
||||
+67
-31
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+19
-10
@@ -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 ->
|
||||
|
||||
+4
-1
@@ -71,7 +71,8 @@ fun MarkdownText (
|
||||
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean = false,
|
||||
showTimestamp: Boolean = true
|
||||
showTimestamp: Boolean = true,
|
||||
prefix: AnnotatedString? = null
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
@@ -123,6 +124,7 @@ fun MarkdownText (
|
||||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
if (text is String) append(text)
|
||||
else if (text is AnnotatedString) append(text)
|
||||
if (meta?.isLive == true) {
|
||||
@@ -136,6 +138,7 @@ fun MarkdownText (
|
||||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
for ((i, ft) in formattedText.withIndex()) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else if (toggleSecrets && ft.format is Format.Secret) {
|
||||
|
||||
+51
-44
@@ -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 = {},
|
||||
|
||||
+126
-129
@@ -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()) } }
|
||||
}
|
||||
+64
-4
@@ -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, {})
|
||||
}
|
||||
}
|
||||
+35
-27
@@ -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?>)
|
||||
|
||||
|
||||
+3
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -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()
|
||||
}
|
||||
|
||||
+7
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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 }
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+28
-13
@@ -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
|
||||
|
||||
+28
-2
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+28
-5
@@ -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 = {
|
||||
|
||||
+13
-11
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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 |
+1
@@ -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 {
|
||||
|
||||
+2
@@ -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"
|
||||
|
||||
+32
-9
@@ -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) {
|
||||
|
||||
+7
-3
@@ -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 {
|
||||
|
||||
+6
@@ -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) }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user