mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2025-03-14 09:45:42 +00:00
Merge branch 'master' into av/node-addon
This commit is contained in:
commit
cdc135cafa
326 changed files with 10489 additions and 4168 deletions
5
.github/workflows/build.yml
vendored
5
.github/workflows/build.yml
vendored
|
@ -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
|
||||
|
|
|
@ -114,11 +114,11 @@ 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
|
||||
}
|
||||
}
|
||||
if chat.isUnread, let tags = chat.chatInfo.chatTags {
|
||||
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
|
||||
for tag in tags {
|
||||
newUnreadTags[tag] = (newUnreadTags[tag] ?? 0) + 1
|
||||
}
|
||||
|
@ -143,49 +143,58 @@ 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markChatTagRead(_ chat: Chat) -> Void {
|
||||
if chat.isUnread, let tags = chat.chatInfo.chatTags {
|
||||
markChatTagRead_(chat, tags)
|
||||
if chat.unreadTag, let tags = chat.chatInfo.chatTags {
|
||||
decTagsReadCount(tags)
|
||||
}
|
||||
}
|
||||
|
||||
func updateChatTagRead(_ chat: Chat, wasUnread: Bool) -> Void {
|
||||
guard let tags = chat.chatInfo.chatTags else { return }
|
||||
let nowUnread = chat.isUnread
|
||||
let nowUnread = chat.unreadTag
|
||||
if nowUnread && !wasUnread {
|
||||
for tag in tags {
|
||||
unreadTags[tag] = (unreadTags[tag] ?? 0) + 1
|
||||
}
|
||||
} else if !nowUnread && wasUnread {
|
||||
markChatTagRead_(chat, tags)
|
||||
decTagsReadCount(tags)
|
||||
}
|
||||
}
|
||||
|
||||
private func markChatTagRead_(_ chat: Chat, _ tags: [Int64]) -> Void {
|
||||
func decTagsReadCount(_ tags: [Int64]) -> Void {
|
||||
for tag in tags {
|
||||
if let count = unreadTags[tag] {
|
||||
unreadTags[tag] = max(0, count - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func changeGroupReportsTag(_ by: Int = 0) {
|
||||
if by == 0 { return }
|
||||
presetTags[.groupReports] = (presetTags[.groupReports] ?? 0) + by
|
||||
}
|
||||
}
|
||||
|
||||
class NetworkModel: ObservableObject {
|
||||
|
@ -432,7 +441,7 @@ final class ChatModel: ObservableObject {
|
|||
updateChatInfo(cInfo)
|
||||
} else if addMissing {
|
||||
addChat(Chat(chatInfo: cInfo, chatItems: []))
|
||||
ChatTagsModel.shared.addPresetChatTags(cInfo)
|
||||
ChatTagsModel.shared.addPresetChatTags(cInfo, ChatStats())
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -694,7 +703,7 @@ final class ChatModel: ObservableObject {
|
|||
// update preview
|
||||
let markedCount = chat.chatStats.unreadCount - unreadBelow
|
||||
if markedCount > 0 {
|
||||
let wasUnread = chat.isUnread
|
||||
let wasUnread = chat.unreadTag
|
||||
chat.chatStats.unreadCount -= markedCount
|
||||
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
|
||||
self.decreaseUnreadCounter(user: self.currentUser!, by: markedCount)
|
||||
|
@ -709,7 +718,7 @@ final class ChatModel: ObservableObject {
|
|||
|
||||
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
|
||||
_updateChat(cInfo.id) { chat in
|
||||
let wasUnread = chat.isUnread
|
||||
let wasUnread = chat.unreadTag
|
||||
chat.chatStats.unreadChat = unreadChat
|
||||
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
|
||||
}
|
||||
|
@ -847,7 +856,7 @@ final class ChatModel: ObservableObject {
|
|||
}
|
||||
|
||||
func changeUnreadCounter(_ chatIndex: Int, by count: Int) {
|
||||
let wasUnread = chats[chatIndex].isUnread
|
||||
let wasUnread = chats[chatIndex].unreadTag
|
||||
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount + count
|
||||
ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread)
|
||||
changeUnreadCounter(user: currentUser!, by: count)
|
||||
|
@ -873,6 +882,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
|
||||
|
@ -956,7 +986,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -995,6 +1026,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ShowingInvitation {
|
||||
|
@ -1055,8 +1103,8 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
|
|||
}
|
||||
}
|
||||
|
||||
var isUnread: Bool {
|
||||
chatStats.unreadCount > 0 || chatStats.unreadChat
|
||||
var unreadTag: Bool {
|
||||
chatInfo.ntfsEnabled && (chatStats.unreadCount > 0 || chatStats.unreadChat)
|
||||
}
|
||||
|
||||
var id: ChatId { get { chatInfo.id } }
|
||||
|
|
|
@ -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 {
|
||||
|
@ -346,7 +340,7 @@ func apiGetChatItems(type: ChatType, id: Int64, pagination: ChatPagination, sear
|
|||
throw r
|
||||
}
|
||||
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
||||
func loadChat(chat: Chat, search: String = "", clearItems: Bool = true, replaceChat: Bool = false) async {
|
||||
do {
|
||||
let cInfo = chat.chatInfo
|
||||
let m = ChatModel.shared
|
||||
|
@ -359,6 +353,9 @@ func loadChat(chat: Chat, search: String = "", clearItems: Bool = true) async {
|
|||
await MainActor.run {
|
||||
im.reversedChatItems = chat.chatItems.reversed()
|
||||
m.updateChatInfo(chat.chatInfo)
|
||||
if (replaceChat) {
|
||||
m.replaceChat(chat.chatInfo.id, chat)
|
||||
}
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("loadChat error: \(responseError(error))")
|
||||
|
@ -460,6 +457,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(
|
||||
|
@ -638,7 +647,13 @@ func getChatItemTTLAsync() async throws -> ChatItemTTL {
|
|||
}
|
||||
|
||||
private func chatItemTTLResponse(_ r: ChatResponse) throws -> ChatItemTTL {
|
||||
if case let .chatItemTTL(_, chatItemTTL) = r { return ChatItemTTL(chatItemTTL) }
|
||||
if case let .chatItemTTL(_, chatItemTTL) = r {
|
||||
if let ttl = chatItemTTL {
|
||||
return ChatItemTTL(ttl)
|
||||
} else {
|
||||
throw RuntimeError("chatItemTTLResponse: invalid ttl")
|
||||
}
|
||||
}
|
||||
throw r
|
||||
}
|
||||
|
||||
|
@ -647,6 +662,11 @@ func setChatItemTTL(_ chatItemTTL: ChatItemTTL) async throws {
|
|||
try await sendCommandOkResp(.apiSetChatItemTTL(userId: userId, seconds: chatItemTTL.seconds))
|
||||
}
|
||||
|
||||
func setChatTTL(chatType: ChatType, id: Int64, _ chatItemTTL: ChatTTL) async throws {
|
||||
let userId = try currentUserId("setChatItemTTL")
|
||||
try await sendCommandOkResp(.apiSetChatTTL(userId: userId, type: chatType, id: id, seconds: chatItemTTL.value))
|
||||
}
|
||||
|
||||
func getNetworkConfig() async throws -> NetCfg? {
|
||||
let r = await chatSendCmd(.apiGetNetworkConfig)
|
||||
if case let .networkConfig(cfg) = r { return cfg }
|
||||
|
@ -846,6 +866,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",
|
||||
|
@ -1026,6 +1058,12 @@ func apiSetContactAlias(contactId: Int64, localAlias: String) async throws -> Co
|
|||
throw r
|
||||
}
|
||||
|
||||
func apiSetGroupAlias(groupId: Int64, localAlias: String) async throws -> GroupInfo? {
|
||||
let r = await chatSendCmd(.apiSetGroupAlias(groupId: groupId, localAlias: localAlias))
|
||||
if case let .groupAliasUpdated(_, toGroup) = r { return toGroup }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiSetConnectionAlias(connId: Int64, localAlias: String) async throws -> PendingContactConnection? {
|
||||
let r = await chatSendCmd(.apiSetConnectionAlias(connId: connId, localAlias: localAlias))
|
||||
if case let .connectionAliasUpdated(_, toConnection) = r { return toConnection }
|
||||
|
@ -1986,6 +2024,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)
|
||||
}
|
||||
|
@ -2049,6 +2090,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 {
|
||||
|
|
|
@ -109,6 +109,7 @@ struct ChatInfoView: View {
|
|||
@State private var showConnectContactViaAddressDialog = false
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@State private var progressIndicator = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
enum ChatInfoViewAlert: Identifiable {
|
||||
|
@ -137,50 +138,48 @@ struct ChatInfoView: View {
|
|||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
contactInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
Group {
|
||||
ZStack {
|
||||
List {
|
||||
contactInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
aliasTextFieldFocused = false
|
||||
}
|
||||
|
||||
localAliasTextEdit()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
GeometryReader { g in
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let buttonWidth = g.size.width / 4
|
||||
searchButton(width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
muteButton(width: buttonWidth)
|
||||
}
|
||||
}
|
||||
.padding(.trailing)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
GeometryReader { g in
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
let buttonWidth = g.size.width / 4
|
||||
searchButton(width: buttonWidth)
|
||||
AudioCallButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
VideoButton(chat: chat, contact: contact, connectionStats: $connectionStats, width: buttonWidth) { alert = .someAlert(alert: $0) }
|
||||
muteButton(width: buttonWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Group {
|
||||
.padding(.trailing)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 8))
|
||||
|
||||
if let customUserProfile = customUserProfile {
|
||||
Section(header: Text("Incognito").foregroundColor(theme.colors.secondary)) {
|
||||
HStack {
|
||||
Text("Your random profile")
|
||||
Spacer()
|
||||
Text(customUserProfile.chatViewName)
|
||||
.foregroundStyle(.indigo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
if let code = connectionCode { verifyCodeButton(code) }
|
||||
contactPreferencesButton()
|
||||
sendReceiptsOption()
|
||||
|
@ -191,97 +190,109 @@ struct ChatInfoView: View {
|
|||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
// } else if developerTools {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
|
||||
Section {
|
||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
Text("Delete chat messages from your device.")
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
|
||||
if let conn = contact.activeConn {
|
||||
Section {
|
||||
infoRow(Text(String("E2E encryption")), conn.connPQEnabled ? "Quantum resistant" : "Standard")
|
||||
}
|
||||
}
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
SimpleXLinkQRCode(uri: contactLink)
|
||||
Button {
|
||||
showShareSheet(items: [simplexChatLink(contactLink)])
|
||||
} label: {
|
||||
Label("Share address", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
} header: {
|
||||
Text("Address")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
} footer: {
|
||||
Text("You can share this address with your contacts to let them connect with **\(contact.displayName)**.")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready && contact.active {
|
||||
Section(header: Text("Servers").foregroundColor(theme.colors.secondary)) {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
alert = .networkStatusAlert
|
||||
}
|
||||
if let connStats = connectionStats {
|
||||
Button("Change receiving address") {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
Button("Abort changing address") {
|
||||
alert = .abortSwitchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil && !$0.canAbortSwitch }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
smpServers("Receiving via", connStats.rcvQueuesInfo.map { $0.rcvServer }, theme.colors.secondary)
|
||||
smpServers("Sending via", connStats.sndQueuesInfo.map { $0.sndServer }, theme.colors.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
deleteContactButton()
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
Button ("Debug delivery") {
|
||||
Task {
|
||||
do {
|
||||
let info = queueInfoText(try await apiContactQueueInfo(chat.chatInfo.apiId))
|
||||
await MainActor.run { alert = .queueInfo(info: info) }
|
||||
} catch let e {
|
||||
logger.error("apiContactQueueInfo error: \(responseError(e))")
|
||||
let a = getErrorAlert(e, "Error")
|
||||
await MainActor.run { alert = .error(title: a.title, error: a.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
.disabled(progressIndicator)
|
||||
.opacity(progressIndicator ? 0.6 : 1)
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.onAppear {
|
||||
|
@ -290,7 +301,6 @@ struct ChatInfoView: View {
|
|||
}
|
||||
sendReceipts = SendReceipts.fromBool(contact.chatSettings.sendRcpts, userDefault: sendReceiptsUserDefault)
|
||||
|
||||
|
||||
Task {
|
||||
do {
|
||||
let (stats, profile) = try await apiContactInfo(chat.chatInfo.apiId)
|
||||
|
@ -498,7 +508,7 @@ struct ChatInfoView: View {
|
|||
chatSettings.sendRcpts = sendReceipts.bool()
|
||||
updateChatSettings(chat, chatSettings: chatSettings)
|
||||
}
|
||||
|
||||
|
||||
private func synchronizeConnectionButton() -> some View {
|
||||
Button {
|
||||
Task {
|
||||
|
@ -643,6 +653,63 @@ struct ChatInfoView: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct ChatTTLOption: View {
|
||||
@ObservedObject var chat: Chat
|
||||
@Binding var progressIndicator: Bool
|
||||
@State private var currentChatItemTTL: ChatTTL = ChatTTL.userDefault(.seconds(0))
|
||||
@State private var chatItemTTL: ChatTTL = ChatTTL.chat(.seconds(0))
|
||||
|
||||
var body: some View {
|
||||
Picker("Delete messages after", selection: $chatItemTTL) {
|
||||
ForEach(ChatItemTTL.values) { ttl in
|
||||
Text(ttl.deleteAfterText).tag(ChatTTL.chat(ttl))
|
||||
}
|
||||
let defaultTTL = ChatTTL.userDefault(ChatModel.shared.chatItemTTL)
|
||||
Text(defaultTTL.text).tag(defaultTTL)
|
||||
|
||||
if case .chat(let ttl) = chatItemTTL, case .seconds = ttl {
|
||||
Text(ttl.deleteAfterText).tag(chatItemTTL)
|
||||
}
|
||||
}
|
||||
.disabled(progressIndicator)
|
||||
.frame(height: 36)
|
||||
.onChange(of: chatItemTTL) { ttl in
|
||||
if ttl == currentChatItemTTL { return }
|
||||
setChatTTL(
|
||||
ttl,
|
||||
hasPreviousTTL: !currentChatItemTTL.neverExpires,
|
||||
onCancel: { chatItemTTL = currentChatItemTTL }
|
||||
) {
|
||||
progressIndicator = true
|
||||
Task {
|
||||
do {
|
||||
try await setChatTTL(chatType: chat.chatInfo.chatType, id: chat.chatInfo.apiId, ttl)
|
||||
await loadChat(chat: chat, clearItems: true, replaceChat: true)
|
||||
await MainActor.run {
|
||||
progressIndicator = false
|
||||
currentChatItemTTL = chatItemTTL
|
||||
}
|
||||
}
|
||||
catch let error {
|
||||
logger.error("setChatTTL error \(responseError(error))")
|
||||
await loadChat(chat: chat, clearItems: true, replaceChat: true)
|
||||
await MainActor.run {
|
||||
chatItemTTL = currentChatItemTTL
|
||||
progressIndicator = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
let sm = ChatModel.shared
|
||||
let ttl = chat.chatInfo.ttl(sm.chatItemTTL)
|
||||
chatItemTTL = ttl
|
||||
currentChatItemTTL = ttl
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func syncContactConnection(_ contact: Contact, force: Bool, showAlert: (SomeAlert) -> Void) async -> ConnectionStats? {
|
||||
do {
|
||||
let stats = try apiSyncContactRatchet(contact.apiId, force)
|
||||
|
@ -1054,6 +1121,33 @@ func deleteContactDialog(
|
|||
}
|
||||
}
|
||||
|
||||
func setChatTTL(_ ttl: ChatTTL, hasPreviousTTL: Bool, onCancel: @escaping () -> Void, onConfirm: @escaping () -> Void) {
|
||||
let title = if ttl.neverExpires {
|
||||
NSLocalizedString("Disable automatic message deletion?", comment: "alert title")
|
||||
} else if ttl.usingDefault || hasPreviousTTL {
|
||||
NSLocalizedString("Change automatic message deletion?", comment: "alert title")
|
||||
} else {
|
||||
NSLocalizedString("Enable automatic message deletion?", comment: "alert title")
|
||||
}
|
||||
|
||||
let message = if ttl.neverExpires {
|
||||
NSLocalizedString("Messages in this chat will never be deleted.", comment: "alert message")
|
||||
} else {
|
||||
NSLocalizedString("This action cannot be undone - the messages sent and received in this chat earlier than selected will be deleted.", comment: "alert message")
|
||||
}
|
||||
|
||||
showAlert(title, message: message) {
|
||||
[
|
||||
UIAlertAction(
|
||||
title: ttl.neverExpires ? NSLocalizedString("Disable delete messages", comment: "alert button") : NSLocalizedString("Delete messages", comment: "alert button"),
|
||||
style: .destructive,
|
||||
handler: { _ in onConfirm() }
|
||||
),
|
||||
UIAlertAction(title: NSLocalizedString("Cancel", comment: "alert button"), style: .cancel, handler: { _ in onCancel() })
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteContactOrConversationDialog(
|
||||
_ chat: Chat,
|
||||
_ contact: Contact,
|
||||
|
@ -1254,7 +1348,7 @@ struct ChatInfoView_Previews: PreviewProvider {
|
|||
localAlias: "",
|
||||
featuresAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
currentFeaturesAllowed: contactUserPrefsToFeaturesAllowed(Contact.sampleData.mergedPreferences),
|
||||
onSearch: {}
|
||||
onSearch: {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -253,7 +253,8 @@ struct ChatView: View {
|
|||
chat.created = Date.now
|
||||
}
|
||||
),
|
||||
onSearch: { focusSearch() }
|
||||
onSearch: { focusSearch() },
|
||||
localAlias: groupInfo.localAlias
|
||||
)
|
||||
}
|
||||
} else if case .local = cInfo {
|
||||
|
@ -917,6 +918,7 @@ struct ChatView: View {
|
|||
|
||||
@State private var allowMenu: Bool = true
|
||||
@State private var markedRead = false
|
||||
@State private var actionSheet: SomeActionSheet? = nil
|
||||
|
||||
var revealed: Bool { chatItem == revealedChatItem }
|
||||
|
||||
|
@ -1001,6 +1003,7 @@ struct ChatView: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.actionSheet(item: $actionSheet) { $0.actionSheet }
|
||||
}
|
||||
|
||||
private func unreadItemIds(_ range: ClosedRange<Int>) -> [ChatItem.ID] {
|
||||
|
@ -1208,7 +1211,7 @@ struct ChatView: View {
|
|||
Button("Delete for me", role: .destructive) {
|
||||
deleteMessage(.cidmInternal, moderate: false)
|
||||
}
|
||||
if let di = deletingItem, di.meta.deletable && !di.localNote {
|
||||
if let di = deletingItem, di.meta.deletable && !di.localNote && !di.isReport {
|
||||
Button(broadcastDeleteButtonText(chat), role: .destructive) {
|
||||
deleteMessage(.cidmBroadcast, moderate: false)
|
||||
}
|
||||
|
@ -1282,7 +1285,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
|
||||
|
@ -1332,8 +1340,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 {
|
||||
|
@ -1607,7 +1619,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),
|
||||
|
@ -1629,10 +1641,7 @@ struct ChatView: View {
|
|||
deletingItem = ci
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
NSLocalizedString("Delete", comment: "chat item action"),
|
||||
systemImage: "trash"
|
||||
)
|
||||
Label(label, systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1651,10 +1660,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)
|
||||
|
@ -1668,6 +1677,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 {
|
||||
|
@ -1707,7 +1734,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?"
|
||||
|
@ -1768,11 +1826,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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1845,6 +1907,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()
|
||||
|
@ -2009,6 +2075,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
|
|||
await MainActor.run {
|
||||
let wasFavorite = chat.chatInfo.chatSettings?.favorite ?? false
|
||||
ChatTagsModel.shared.updateChatFavorite(favorite: chatSettings.favorite, wasFavorite: wasFavorite)
|
||||
let wasUnread = chat.unreadTag
|
||||
switch chat.chatInfo {
|
||||
case var .direct(contact):
|
||||
contact.chatSettings = chatSettings
|
||||
|
@ -2018,6 +2085,7 @@ func updateChatSettings(_ chat: Chat, chatSettings: ChatSettings) {
|
|||
ChatModel.shared.updateGroup(groupInfo)
|
||||
default: ()
|
||||
}
|
||||
ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread)
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiSetChatSettings error \(responseError(error))")
|
||||
|
|
|
@ -24,6 +24,7 @@ enum ComposeContextItem {
|
|||
case quotedItem(chatItem: ChatItem)
|
||||
case editingItem(chatItem: ChatItem)
|
||||
case forwardingItems(chatItems: [ChatItem], fromChatInfo: ChatInfo)
|
||||
case reportedItem(chatItem: ChatItem, reason: ReportReason)
|
||||
}
|
||||
|
||||
enum VoiceMessageRecordingState {
|
||||
|
@ -116,13 +117,31 @@ struct ComposeState {
|
|||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var reporting: Bool {
|
||||
switch contextItem {
|
||||
case .reportedItem: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var submittingValidReport: Bool {
|
||||
switch contextItem {
|
||||
case let .reportedItem(_, reason):
|
||||
switch reason {
|
||||
case .other: return !message.isEmpty
|
||||
default: return true
|
||||
}
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var sendEnabled: Bool {
|
||||
switch preview {
|
||||
case let .mediaPreviews(media): return !media.isEmpty
|
||||
case .voicePreview: return voiceMessageRecordingState == .finished
|
||||
case .filePreview: return true
|
||||
default: return !message.isEmpty || forwarding || liveMessage != nil
|
||||
default: return !message.isEmpty || forwarding || liveMessage != nil || submittingValidReport
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -175,7 +194,7 @@ struct ComposeState {
|
|||
}
|
||||
|
||||
var attachmentDisabled: Bool {
|
||||
if editing || forwarding || liveMessage != nil || inProgress { return true }
|
||||
if editing || forwarding || liveMessage != nil || inProgress || reporting { return true }
|
||||
switch preview {
|
||||
case .noPreview: return false
|
||||
case .linkPreview: return false
|
||||
|
@ -193,6 +212,15 @@ struct ComposeState {
|
|||
}
|
||||
}
|
||||
|
||||
var placeholder: String? {
|
||||
switch contextItem {
|
||||
case let .reportedItem(_, reason):
|
||||
return reason.text
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var empty: Bool {
|
||||
message == "" && noPreview
|
||||
}
|
||||
|
@ -297,6 +325,11 @@ struct ComposeView: View {
|
|||
ContextInvitingContactMemberView()
|
||||
Divider()
|
||||
}
|
||||
|
||||
if case let .reportedItem(_, reason) = composeState.contextItem {
|
||||
reportReasonView(reason)
|
||||
Divider()
|
||||
}
|
||||
// preference checks should match checks in forwarding list
|
||||
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
|
||||
let fileProhibited = composeState.attachmentPreview && !chat.groupFeatureEnabled(.files)
|
||||
|
@ -686,6 +719,27 @@ struct ComposeView: View {
|
|||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
|
||||
private func reportReasonView(_ reason: ReportReason) -> some View {
|
||||
let reportText = switch reason {
|
||||
case .spam: NSLocalizedString("Report spam: only group moderators will see it.", comment: "report reason")
|
||||
case .profile: NSLocalizedString("Report member profile: only group moderators will see it.", comment: "report reason")
|
||||
case .community: NSLocalizedString("Report violation: only group moderators will see it.", comment: "report reason")
|
||||
case .illegal: NSLocalizedString("Report content: only group moderators will see it.", comment: "report reason")
|
||||
case .other: NSLocalizedString("Report other: only group moderators will see it.", comment: "report reason")
|
||||
case .unknown: "" // Should never happen
|
||||
}
|
||||
|
||||
return Text(reportText)
|
||||
.italic()
|
||||
.font(.caption)
|
||||
.padding(12)
|
||||
.frame(minHeight: 44)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(.thinMaterial)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder private func contextItemView() -> some View {
|
||||
switch composeState.contextItem {
|
||||
|
@ -715,6 +769,15 @@ struct ComposeView: View {
|
|||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
|
||||
)
|
||||
Divider()
|
||||
case let .reportedItem(chatItem: reportedItem, _):
|
||||
ContextItemView(
|
||||
chat: chat,
|
||||
contextItems: [reportedItem],
|
||||
contextIcon: "flag",
|
||||
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) },
|
||||
contextIconForeground: Color.red
|
||||
)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -746,6 +809,8 @@ struct ComposeView: View {
|
|||
sent = await updateMessage(ci, live: live)
|
||||
} else if let liveMessage = liveMessage, liveMessage.sentMsg != nil {
|
||||
sent = await updateMessage(liveMessage.chatItem, live: live)
|
||||
} else if case let .reportedItem(chatItem, reason) = composeState.contextItem {
|
||||
sent = await send(reason, chatItemId: chatItem.id)
|
||||
} else {
|
||||
var quoted: Int64? = nil
|
||||
if case let .quotedItem(chatItem: quotedItem) = composeState.contextItem {
|
||||
|
@ -872,6 +937,8 @@ struct ComposeView: View {
|
|||
return .voice(text: msgText, duration: duration)
|
||||
case .file:
|
||||
return .file(msgText)
|
||||
case .report(_, let reason):
|
||||
return .report(text: msgText, reason: reason)
|
||||
case .unknown(let type, _):
|
||||
return .unknown(type: type, text: msgText)
|
||||
}
|
||||
|
@ -891,7 +958,25 @@ struct ComposeView: View {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func send(_ reportReason: ReportReason, chatItemId: Int64) async -> ChatItem? {
|
||||
if let chatItems = await apiReportMessage(
|
||||
groupId: chat.chatInfo.apiId,
|
||||
chatItemId: chatItemId,
|
||||
reportReason: reportReason,
|
||||
reportText: msgText
|
||||
) {
|
||||
await MainActor.run {
|
||||
for chatItem in chatItems {
|
||||
chatModel.addChatItem(chat.chatInfo, chatItem)
|
||||
}
|
||||
}
|
||||
return chatItems.first
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func send(_ mc: MsgContent, quoted: Int64?, file: CryptoFile? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? {
|
||||
await send(
|
||||
[ComposedMessage(fileSource: file, quotedItemId: quoted, msgContent: mc)],
|
||||
|
|
|
@ -15,6 +15,7 @@ struct ContextItemView: View {
|
|||
let contextItems: [ChatItem]
|
||||
let contextIcon: String
|
||||
let cancelContextItem: () -> Void
|
||||
var contextIconForeground: Color? = nil
|
||||
var showSender: Bool = true
|
||||
|
||||
var body: some View {
|
||||
|
@ -23,7 +24,7 @@ struct ContextItemView: View {
|
|||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 16, height: 16)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.foregroundColor(contextIconForeground ?? theme.colors.secondary)
|
||||
if let singleItem = contextItems.first, contextItems.count == 1 {
|
||||
if showSender, let sender = singleItem.memberDisplayName {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
|
@ -93,6 +94,6 @@ struct ContextItemView: View {
|
|||
struct ContextItemView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
|
||||
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {})
|
||||
return ContextItemView(chat: Chat.sampleData, contextItems: [contextItem], contextIcon: "pencil.circle", cancelContextItem: {}, contextIconForeground: Color.red)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||
@Binding var disableEditing: Bool
|
||||
@Binding var height: CGFloat
|
||||
@Binding var focused: Bool
|
||||
@Binding var placeholder: String?
|
||||
let onImagesAdded: ([UploadContent]) -> Void
|
||||
|
||||
private let minHeight: CGFloat = 37
|
||||
|
@ -50,6 +51,7 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||
field.setOnFocusChangedListener { focused = $0 }
|
||||
field.delegate = field
|
||||
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
|
||||
field.setPlaceholderView()
|
||||
updateFont(field)
|
||||
updateHeight(field)
|
||||
return field
|
||||
|
@ -62,6 +64,11 @@ struct NativeTextEditor: UIViewRepresentable {
|
|||
updateFont(field)
|
||||
updateHeight(field)
|
||||
}
|
||||
|
||||
let castedField = field as! CustomUITextField
|
||||
if castedField.placeholder != placeholder {
|
||||
castedField.placeholder = placeholder
|
||||
}
|
||||
}
|
||||
|
||||
private func updateHeight(_ field: UITextView) {
|
||||
|
@ -97,11 +104,18 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
|||
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
|
||||
var onFocusChanged: (Bool) -> Void = { focused in }
|
||||
|
||||
private let placeholderLabel: UILabel = UILabel()
|
||||
|
||||
init(height: Binding<CGFloat>) {
|
||||
self.height = height
|
||||
super.init(frame: .zero, textContainer: nil)
|
||||
}
|
||||
|
||||
var placeholder: String? {
|
||||
get { placeholderLabel.text }
|
||||
set { placeholderLabel.text = newValue }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("Not implemented")
|
||||
}
|
||||
|
@ -124,6 +138,20 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
|||
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
|
||||
self.onTextChanged = onTextChanged
|
||||
}
|
||||
|
||||
func setPlaceholderView() {
|
||||
placeholderLabel.textColor = .lightGray
|
||||
placeholderLabel.font = UIFont.preferredFont(forTextStyle: .body)
|
||||
placeholderLabel.isHidden = !text.isEmpty
|
||||
placeholderLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(placeholderLabel)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
placeholderLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 7),
|
||||
placeholderLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -7),
|
||||
placeholderLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8)
|
||||
])
|
||||
}
|
||||
|
||||
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
|
||||
self.onFocusChanged = onFocusChanged
|
||||
|
@ -172,6 +200,7 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
|
|||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
placeholderLabel.isHidden = !text.isEmpty
|
||||
if textView.markedTextRange == nil {
|
||||
var images: [UploadContent] = []
|
||||
var rangeDiff = 0
|
||||
|
@ -217,6 +246,7 @@ struct NativeTextEditor_Previews: PreviewProvider{
|
|||
disableEditing: Binding.constant(false),
|
||||
height: Binding.constant(100),
|
||||
focused: Binding.constant(false),
|
||||
placeholder: Binding.constant("Placeholder"),
|
||||
onImagesAdded: { _ in }
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
|
|
@ -61,6 +61,7 @@ struct SendMessageView: View {
|
|||
disableEditing: $composeState.inProgress,
|
||||
height: $teHeight,
|
||||
focused: $keyboardVisible,
|
||||
placeholder: Binding(get: { composeState.placeholder }, set: { _ in }),
|
||||
onImagesAdded: onMediaAdded
|
||||
)
|
||||
.allowsTightening(false)
|
||||
|
@ -105,6 +106,8 @@ struct SendMessageView: View {
|
|||
let vmrs = composeState.voiceMessageRecordingState
|
||||
if nextSendGrpInv {
|
||||
inviteMemberContactButton()
|
||||
} else if case .reportedItem = composeState.contextItem {
|
||||
sendMessageButton()
|
||||
} else if showVoiceMessageButton
|
||||
&& composeState.message.isEmpty
|
||||
&& !composeState.editing
|
||||
|
|
|
@ -175,10 +175,8 @@ struct AddGroupMembersViewCommon: View {
|
|||
|
||||
private func rolePicker() -> some View {
|
||||
Picker("New member role", selection: $selectedRole) {
|
||||
ForEach(GroupMemberRole.allCases) { role in
|
||||
if role <= groupInfo.membership.memberRole && role != .author {
|
||||
Text(role.text)
|
||||
}
|
||||
ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in
|
||||
Text(role.text)
|
||||
}
|
||||
}
|
||||
.frame(height: 36)
|
||||
|
|
|
@ -18,6 +18,8 @@ struct GroupChatInfoView: View {
|
|||
@ObservedObject var chat: Chat
|
||||
@Binding var groupInfo: GroupInfo
|
||||
var onSearch: () -> Void
|
||||
@State var localAlias: String
|
||||
@FocusState private var aliasTextFieldFocused: Bool
|
||||
@State private var alert: GroupChatInfoViewAlert? = nil
|
||||
@State private var groupLink: String?
|
||||
@State private var groupLinkMemberRole: GroupMemberRole = .member
|
||||
|
@ -27,6 +29,7 @@ struct GroupChatInfoView: View {
|
|||
@State private var connectionCode: String?
|
||||
@State private var sendReceipts = SendReceipts.userDefault(true)
|
||||
@State private var sendReceiptsUserDefault = true
|
||||
@State private var progressIndicator = false
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
@State private var searchText: String = ""
|
||||
@FocusState private var searchFocussed
|
||||
|
@ -67,101 +70,120 @@ struct GroupChatInfoView: View {
|
|||
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
|
||||
.sorted { $0.wrapped.memberRole > $1.wrapped.memberRole }
|
||||
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
infoActionButtons()
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editGroupButton()
|
||||
}
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
ZStack {
|
||||
List {
|
||||
groupInfoHeader()
|
||||
.listRowBackground(Color.clear)
|
||||
|
||||
localAliasTextEdit()
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
infoActionButtons()
|
||||
.padding(.horizontal)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: infoViewActionButtonHeight)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
|
||||
|
||||
Section {
|
||||
if groupInfo.isOwner && groupInfo.businessChat == nil {
|
||||
editGroupButton()
|
||||
}
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { alert = .cantInviteIncognitoAlert }
|
||||
if groupInfo.groupProfile.description != nil || (groupInfo.isOwner && groupInfo.businessChat == nil) {
|
||||
addOrEditWelcomeMessage()
|
||||
}
|
||||
GroupPreferencesButton(groupInfo: $groupInfo, preferences: groupInfo.fullGroupPreferences, currentPreferences: groupInfo.fullGroupPreferences)
|
||||
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
|
||||
sendReceiptsOption()
|
||||
} else {
|
||||
addMembersButton()
|
||||
sendReceiptsOptionDisabled()
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
ChatWallpaperEditorSheet(chat: chat)
|
||||
} label: {
|
||||
Label("Chat theme", systemImage: "photo")
|
||||
}
|
||||
} header: {
|
||||
Text("")
|
||||
} footer: {
|
||||
let label: LocalizedStringKey = (
|
||||
groupInfo.businessChat == nil
|
||||
? "Only group owners can change group preferences."
|
||||
: "Only chat owners can change preferences."
|
||||
)
|
||||
Text(label)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
if members.count > 8 {
|
||||
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||
.padding(.leading, 8)
|
||||
|
||||
Section {
|
||||
ChatTTLOption(chat: chat, progressIndicator: $progressIndicator)
|
||||
} footer: {
|
||||
Text("Delete chat messages from your device.")
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member)
|
||||
} label: {
|
||||
EmptyView()
|
||||
|
||||
Section(header: Text("\(members.count + 1) members").foregroundColor(theme.colors.secondary)) {
|
||||
if groupInfo.canAddMembers {
|
||||
if groupInfo.businessChat == nil {
|
||||
groupLinkButton()
|
||||
}
|
||||
.opacity(0)
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
if (chat.chatInfo.incognito) {
|
||||
Label("Invite members", systemImage: "plus")
|
||||
.foregroundColor(Color(uiColor: .tertiaryLabel))
|
||||
.onTapGesture { alert = .cantInviteIncognitoAlert }
|
||||
} else {
|
||||
addMembersButton()
|
||||
}
|
||||
}
|
||||
if members.count > 8 {
|
||||
searchFieldView(text: $searchText, focussed: $searchFocussed, theme.colors.onBackground, theme.colors.secondary)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
|
||||
let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
|
||||
ForEach(filteredMembers) { member in
|
||||
ZStack {
|
||||
NavigationLink {
|
||||
memberInfoView(member)
|
||||
} label: {
|
||||
EmptyView()
|
||||
}
|
||||
.opacity(0)
|
||||
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
}
|
||||
if groupInfo.membership.memberCurrent {
|
||||
leaveGroupButton()
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
clearChatButton()
|
||||
if groupInfo.canDelete {
|
||||
deleteGroupButton()
|
||||
}
|
||||
if groupInfo.membership.memberCurrent {
|
||||
leaveGroupButton()
|
||||
}
|
||||
}
|
||||
|
||||
if developerTools {
|
||||
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
|
||||
infoRow("Local name", chat.chatInfo.localDisplayName)
|
||||
infoRow("Database ID", "\(chat.chatInfo.apiId)")
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
.disabled(progressIndicator)
|
||||
.opacity(progressIndicator ? 0.6 : 1)
|
||||
|
||||
if progressIndicator {
|
||||
ProgressView().scaleEffect(2)
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||||
.alert(item: $alert) { alertItem in
|
||||
|
@ -200,7 +222,7 @@ struct GroupChatInfoView: View {
|
|||
ChatInfoImage(chat: chat, size: 192, color: Color(uiColor: .tertiarySystemFill))
|
||||
.padding(.top, 12)
|
||||
.padding()
|
||||
Text(cInfo.displayName)
|
||||
Text(cInfo.groupInfo?.groupProfile.displayName ?? cInfo.displayName)
|
||||
.font(.largeTitle)
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(4)
|
||||
|
@ -215,6 +237,37 @@ struct GroupChatInfoView: View {
|
|||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
private func localAliasTextEdit() -> some View {
|
||||
TextField("Set chat name…", text: $localAlias)
|
||||
.disableAutocorrection(true)
|
||||
.focused($aliasTextFieldFocused)
|
||||
.submitLabel(.done)
|
||||
.onChange(of: aliasTextFieldFocused) { focused in
|
||||
if !focused {
|
||||
setGroupAlias()
|
||||
}
|
||||
}
|
||||
.onSubmit {
|
||||
setGroupAlias()
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
|
||||
private func setGroupAlias() {
|
||||
Task {
|
||||
do {
|
||||
if let gInfo = try await apiSetGroupAlias(groupId: chat.chatInfo.apiId, localAlias: localAlias) {
|
||||
await MainActor.run {
|
||||
chatModel.updateGroup(gInfo)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
logger.error("setGroupAlias error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func infoActionButtons() -> some View {
|
||||
GeometryReader { g in
|
||||
let buttonWidth = g.size.width / 4
|
||||
|
@ -739,7 +792,8 @@ struct GroupChatInfoView_Previews: PreviewProvider {
|
|||
GroupChatInfoView(
|
||||
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
|
||||
groupInfo: Binding.constant(GroupInfo.sampleData),
|
||||
onSearch: {}
|
||||
onSearch: {},
|
||||
localAlias: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,389 +559,6 @@ struct ChatListNavLink: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct TagEditorNavParams {
|
||||
let chat: Chat?
|
||||
let chatListTag: ChatTagData?
|
||||
let tagId: Int64?
|
||||
}
|
||||
|
||||
struct ChatListTag: View {
|
||||
var chat: Chat? = nil
|
||||
var showEditButton: Bool = false
|
||||
@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 {
|
||||
setTag(tagId: selected ? nil : tagId, chat: c)
|
||||
} 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 showEditButton {
|
||||
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 setTag(tagId: Int64?, chat: Chat) {
|
||||
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 {
|
||||
chatTagsModel.userTags = userTags
|
||||
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)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
} catch let error {
|
||||
showAlert(
|
||||
NSLocalizedString("Error saving chat list", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
var chat: Chat? = nil
|
||||
var tagId: Int64? = nil
|
||||
@Environment(\.dismiss) var dismiss: DismissAction
|
||||
@EnvironmentObject var chatTagsModel: ChatTagsModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
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(NSLocalizedString(tagId == nil ? "Create list" : "Save list", comment: "list editor button"))
|
||||
}
|
||||
.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 userTags = try await apiCreateChatTag(
|
||||
tag: ChatTagData(emoji: newEmoji , text: trimmedName)
|
||||
)
|
||||
await MainActor.run {
|
||||
saving = false
|
||||
chatTagsModel.userTags = userTags
|
||||
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, showEditButton: true)
|
||||
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)
|
||||
|
|
408
apps/ios/Shared/Views/ChatList/TagListView.swift
Normal file
408
apps/ios/Shared/Views/ChatList/TagListView.swift
Normal file
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -53,7 +53,7 @@ struct OperatorView: View {
|
|||
ServersErrorView(errStr: errStr)
|
||||
} else {
|
||||
switch (userServers[operatorIndex].operator_.conditionsAcceptance) {
|
||||
case let .accepted(acceptedAt):
|
||||
case let .accepted(acceptedAt, _):
|
||||
if let acceptedAt = acceptedAt {
|
||||
Text("Conditions accepted on: \(conditionsTimestamp(acceptedAt)).")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
|
|
|
@ -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)
|
||||
|
|
56
apps/ios/Shared/Views/UserSettings/StorageView.swift
Normal file
56
apps/ios/Shared/Views/UserSettings/StorageView.swift
Normal file
|
@ -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.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */; };
|
||||
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */; };
|
||||
649B28DF2CFE07CF00536B68 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DA2CFE07CF00536B68 /* libgmpxx.a */; };
|
||||
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */; };
|
||||
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.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 */; };
|
||||
|
@ -200,9 +200,11 @@
|
|||
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.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 */; };
|
||||
|
@ -517,9 +519,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.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a"; sourceTree = "<group>"; };
|
||||
649B28DA2CFE07CF00536B68 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a"; sourceTree = "<group>"; };
|
||||
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.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>"; };
|
||||
|
@ -549,9 +551,11 @@
|
|||
8C86EBE42C0DAE4F00E12243 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.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>"; };
|
||||
|
@ -673,9 +677,9 @@
|
|||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
649B28E12CFE07CF00536B68 /* libgmp.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a in Frameworks */,
|
||||
649B28E02CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a in Frameworks */,
|
||||
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
|
||||
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a in Frameworks */,
|
||||
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a in Frameworks */,
|
||||
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -756,8 +760,8 @@
|
|||
649B28D82CFE07CF00536B68 /* libffi.a */,
|
||||
649B28DC2CFE07CF00536B68 /* libgmp.a */,
|
||||
649B28DA2CFE07CF00536B68 /* libgmpxx.a */,
|
||||
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW-ghc9.6.3.a */,
|
||||
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.2.0.7-1UFyc6WJuaw6s2eEIvrMnW.a */,
|
||||
649B28D92CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte-ghc9.6.3.a */,
|
||||
649B28DB2CFE07CF00536B68 /* libHSsimplex-chat-6.3.0.1-3BhXD2AWEfb4OJmT8vyXte.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
|
@ -945,6 +949,7 @@
|
|||
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */,
|
||||
5CEBD7472A5F115D00665FE2 /* SetDeliveryReceiptsView.swift */,
|
||||
8C69FE7C2B8C7D2700267E38 /* AppSettings.swift */,
|
||||
8CBC14852D357CDB00BBD901 /* StorageView.swift */,
|
||||
);
|
||||
path = UserSettings;
|
||||
sourceTree = "<group>";
|
||||
|
@ -962,6 +967,7 @@
|
|||
18415835CBD939A9ABDC108A /* UserPicker.swift */,
|
||||
64EEB0F62C353F1C00972D62 /* ServersSummaryView.swift */,
|
||||
E51CC1E52C62085600DB91FE /* OneHandUICard.swift */,
|
||||
B70A39722D24090D00E80A5F /* TagListView.swift */,
|
||||
);
|
||||
path = ChatList;
|
||||
sourceTree = "<group>";
|
||||
|
@ -1457,6 +1463,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 */,
|
||||
|
@ -1526,6 +1533,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 */,
|
||||
|
@ -1935,7 +1943,7 @@
|
|||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
@ -1960,7 +1968,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES_THIN;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -1984,7 +1992,7 @@
|
|||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
@ -2009,7 +2017,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2025,11 +2033,11 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2045,11 +2053,11 @@
|
|||
buildSettings = {
|
||||
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2070,7 +2078,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GCC_OPTIMIZATION_LEVEL = s;
|
||||
|
@ -2085,7 +2093,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -2107,7 +2115,7 @@
|
|||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_CODE_COVERAGE = NO;
|
||||
|
@ -2122,7 +2130,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
@ -2144,7 +2152,7 @@
|
|||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -2170,7 +2178,7 @@
|
|||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2195,7 +2203,7 @@
|
|||
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
|
||||
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
|
@ -2221,7 +2229,7 @@
|
|||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
LLVM_LTO = YES;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2246,7 +2254,7 @@
|
|||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
|
@ -2261,7 +2269,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
@ -2280,7 +2288,7 @@
|
|||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 255;
|
||||
CURRENT_PROJECT_VERSION = 260;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
|
@ -2295,7 +2303,7 @@
|
|||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 6.2.1;
|
||||
MARKETING_VERSION = 6.3;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
|
|
@ -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])
|
||||
|
@ -88,8 +89,9 @@ public enum ChatCommand {
|
|||
case apiGetUsageConditions
|
||||
case apiSetConditionsNotified(conditionsId: Int64)
|
||||
case apiAcceptConditions(conditionsId: Int64, operatorIds: [Int64])
|
||||
case apiSetChatItemTTL(userId: Int64, seconds: Int64?)
|
||||
case apiSetChatItemTTL(userId: Int64, seconds: Int64)
|
||||
case apiGetChatItemTTL(userId: Int64)
|
||||
case apiSetChatTTL(userId: Int64, type: ChatType, id: Int64, seconds: Int64?)
|
||||
case apiSetNetworkConfig(networkConfig: NetCfg)
|
||||
case apiGetNetworkConfig
|
||||
case apiSetNetworkInfo(networkInfo: UserNetworkInfo)
|
||||
|
@ -123,6 +125,7 @@ public enum ChatCommand {
|
|||
case apiUpdateProfile(userId: Int64, profile: Profile)
|
||||
case apiSetContactPrefs(contactId: Int64, preferences: Preferences)
|
||||
case apiSetContactAlias(contactId: Int64, localAlias: String)
|
||||
case apiSetGroupAlias(groupId: Int64, localAlias: String)
|
||||
case apiSetConnectionAlias(connId: Int64, localAlias: String)
|
||||
case apiSetUserUIThemes(userId: Int64, themes: ThemeModeOverrides?)
|
||||
case apiSetChatUIThemes(chatId: String, themes: ThemeModeOverrides?)
|
||||
|
@ -221,6 +224,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: ","))"
|
||||
|
@ -262,6 +267,7 @@ public enum ChatCommand {
|
|||
case let .apiAcceptConditions(conditionsId, operatorIds): return "/_accept_conditions \(conditionsId) \(joinedIds(operatorIds))"
|
||||
case let .apiSetChatItemTTL(userId, seconds): return "/_ttl \(userId) \(chatItemTTLStr(seconds: seconds))"
|
||||
case let .apiGetChatItemTTL(userId): return "/_ttl \(userId)"
|
||||
case let .apiSetChatTTL(userId, type, id, seconds): return "/_ttl \(userId) \(ref(type, id)) \(chatItemTTLStr(seconds: seconds))"
|
||||
case let .apiSetNetworkConfig(networkConfig): return "/_network \(encodeJSON(networkConfig))"
|
||||
case .apiGetNetworkConfig: return "/network"
|
||||
case let .apiSetNetworkInfo(networkInfo): return "/_network info \(encodeJSON(networkInfo))"
|
||||
|
@ -305,6 +311,7 @@ public enum ChatCommand {
|
|||
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
|
||||
case let .apiSetContactPrefs(contactId, preferences): return "/_set prefs @\(contactId) \(encodeJSON(preferences))"
|
||||
case let .apiSetContactAlias(contactId, localAlias): return "/_set alias @\(contactId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case let .apiSetGroupAlias(groupId, localAlias): return "/_set alias #\(groupId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case let .apiSetConnectionAlias(connId, localAlias): return "/_set alias :\(connId) \(localAlias.trimmingCharacters(in: .whitespaces))"
|
||||
case let .apiSetUserUIThemes(userId, themes): return "/_set theme user \(userId) \(themes != nil ? encodeJSON(themes) : "")"
|
||||
case let .apiSetChatUIThemes(chatId, themes): return "/_set theme \(chatId) \(themes != nil ? encodeJSON(themes) : "")"
|
||||
|
@ -390,6 +397,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"
|
||||
|
@ -430,6 +438,7 @@ public enum ChatCommand {
|
|||
case .apiAcceptConditions: return "apiAcceptConditions"
|
||||
case .apiSetChatItemTTL: return "apiSetChatItemTTL"
|
||||
case .apiGetChatItemTTL: return "apiGetChatItemTTL"
|
||||
case .apiSetChatTTL: return "apiSetChatTTL"
|
||||
case .apiSetNetworkConfig: return "apiSetNetworkConfig"
|
||||
case .apiGetNetworkConfig: return "apiGetNetworkConfig"
|
||||
case .apiSetNetworkInfo: return "apiSetNetworkInfo"
|
||||
|
@ -462,6 +471,7 @@ public enum ChatCommand {
|
|||
case .apiUpdateProfile: return "apiUpdateProfile"
|
||||
case .apiSetContactPrefs: return "apiSetContactPrefs"
|
||||
case .apiSetContactAlias: return "apiSetContactAlias"
|
||||
case .apiSetGroupAlias: return "apiSetGroupAlias"
|
||||
case .apiSetConnectionAlias: return "apiSetConnectionAlias"
|
||||
case .apiSetUserUIThemes: return "apiSetUserUIThemes"
|
||||
case .apiSetChatUIThemes: return "apiSetChatUIThemes"
|
||||
|
@ -519,7 +529,7 @@ public enum ChatCommand {
|
|||
if let seconds = seconds {
|
||||
return String(seconds)
|
||||
} else {
|
||||
return "none"
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -625,6 +635,7 @@ public enum ChatResponse: Decodable, Error {
|
|||
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
|
||||
case userPrivacy(user: User, updatedUser: User)
|
||||
case contactAliasUpdated(user: UserRef, toContact: Contact)
|
||||
case groupAliasUpdated(user: UserRef, toGroup: GroupInfo)
|
||||
case connectionAliasUpdated(user: UserRef, toConnection: PendingContactConnection)
|
||||
case contactPrefsUpdated(user: User, fromContact: Contact, toContact: Contact)
|
||||
case userContactLink(user: User, contactLink: UserContactLink)
|
||||
|
@ -646,6 +657,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)
|
||||
|
@ -804,6 +816,7 @@ public enum ChatResponse: Decodable, Error {
|
|||
case .userProfileUpdated: return "userProfileUpdated"
|
||||
case .userPrivacy: return "userPrivacy"
|
||||
case .contactAliasUpdated: return "contactAliasUpdated"
|
||||
case .groupAliasUpdated: return "groupAliasUpdated"
|
||||
case .connectionAliasUpdated: return "connectionAliasUpdated"
|
||||
case .contactPrefsUpdated: return "contactPrefsUpdated"
|
||||
case .userContactLink: return "userContactLink"
|
||||
|
@ -825,6 +838,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"
|
||||
|
@ -981,6 +995,7 @@ public enum ChatResponse: Decodable, Error {
|
|||
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
|
||||
case let .userPrivacy(u, updatedUser): return withUser(u, String(describing: updatedUser))
|
||||
case let .contactAliasUpdated(u, toContact): return withUser(u, String(describing: toContact))
|
||||
case let .groupAliasUpdated(u, toGroup): return withUser(u, String(describing: toGroup))
|
||||
case let .connectionAliasUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
|
||||
case let .contactPrefsUpdated(u, fromContact, toContact): return withUser(u, "fromContact: \(String(describing: fromContact))\ntoContact: \(String(describing: toContact))")
|
||||
case let .userContactLink(u, contactLink): return withUser(u, contactLink.responseDetails)
|
||||
|
@ -1004,6 +1019,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")
|
||||
|
@ -1186,12 +1203,14 @@ public enum ChatPagination {
|
|||
case last(count: Int)
|
||||
case after(chatItemId: Int64, count: Int)
|
||||
case before(chatItemId: Int64, count: Int)
|
||||
case around(chatItemId: Int64, count: Int)
|
||||
|
||||
var cmdString: String {
|
||||
switch self {
|
||||
case let .last(count): return "count=\(count)"
|
||||
case let .after(chatItemId, count): return "after=\(chatItemId) count=\(count)"
|
||||
case let .before(chatItemId, count): return "before=\(chatItemId) count=\(count)"
|
||||
case let .around(chatItemId, count): return "around=\(chatItemId) count=\(count)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1324,7 +1343,7 @@ public struct ServerOperatorConditions: Decodable {
|
|||
}
|
||||
|
||||
public enum ConditionsAcceptance: Equatable, Codable, Hashable {
|
||||
case accepted(acceptedAt: Date?)
|
||||
case accepted(acceptedAt: Date?, autoAccepted: Bool)
|
||||
// If deadline is present, it means there's a grace period to review and accept conditions during which user can continue to use the operator.
|
||||
// No deadline indicates it's required to accept conditions for the operator to start using it.
|
||||
case required(deadline: Date?)
|
||||
|
@ -1398,7 +1417,7 @@ public struct ServerOperator: Identifiable, Equatable, Codable {
|
|||
tradeName: "SimpleX Chat",
|
||||
legalName: "SimpleX Chat Ltd",
|
||||
serverDomains: ["simplex.im"],
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil),
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
|
||||
enabled: true,
|
||||
smpRoles: ServerRoles(storage: true, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: true)
|
||||
|
@ -1431,7 +1450,7 @@ public struct UserOperatorServers: Identifiable, Equatable, Codable {
|
|||
tradeName: "",
|
||||
legalName: "",
|
||||
serverDomains: [],
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil),
|
||||
conditionsAcceptance: .accepted(acceptedAt: nil, autoAccepted: false),
|
||||
enabled: false,
|
||||
smpRoles: ServerRoles(storage: true, proxy: true),
|
||||
xftpRoles: ServerRoles(storage: true, proxy: true)
|
||||
|
@ -2207,6 +2226,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
|
||||
|
@ -2471,6 +2506,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)
|
||||
|
@ -2487,11 +2523,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
|
||||
|
@ -2630,6 +2683,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
|
||||
|
@ -2665,6 +2719,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 }
|
||||
|
@ -2701,6 +2756,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-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption")!
|
||||
|
||||
public struct User: Identifiable, Decodable, UserLike, NamedChat, Hashable {
|
||||
public var userId: Int64
|
||||
public var agentUserId: String
|
||||
|
@ -1492,6 +1500,24 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
|
|||
case .invalidJSON: return .now
|
||||
}
|
||||
}
|
||||
|
||||
public func ttl(_ globalTTL: ChatItemTTL) -> ChatTTL {
|
||||
switch self {
|
||||
case let .direct(contact):
|
||||
return if let ciTTL = contact.chatItemTTL {
|
||||
ChatTTL.chat(ChatItemTTL(ciTTL))
|
||||
} else {
|
||||
ChatTTL.userDefault(globalTTL)
|
||||
}
|
||||
case let .group(groupInfo):
|
||||
return if let ciTTL = groupInfo.chatItemTTL {
|
||||
ChatTTL.chat(ChatItemTTL(ciTTL))
|
||||
} else {
|
||||
ChatTTL.userDefault(globalTTL)
|
||||
}
|
||||
default: return ChatTTL.userDefault(globalTTL)
|
||||
}
|
||||
}
|
||||
|
||||
public struct SampleData: Hashable {
|
||||
public var direct: ChatInfo
|
||||
|
@ -1533,13 +1559,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
|
||||
}
|
||||
|
@ -1561,6 +1590,7 @@ public struct Contact: Identifiable, Decodable, NamedChat, Hashable {
|
|||
var contactGroupMemberId: Int64?
|
||||
var contactGrpInvSent: Bool
|
||||
public var chatTags: [Int64]
|
||||
public var chatItemTTL: Int64?
|
||||
public var uiThemes: ThemeModeOverrides?
|
||||
public var chatDeleted: Bool
|
||||
|
||||
|
@ -1695,7 +1725,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 +1737,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 +1795,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,
|
||||
|
@ -1923,11 +1949,12 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
|
|||
public var apiId: Int64 { get { groupId } }
|
||||
public var ready: Bool { get { true } }
|
||||
public var sendMsgEnabled: Bool { get { membership.memberActive } }
|
||||
public var displayName: String { get { groupProfile.displayName } }
|
||||
public var displayName: String { localAlias == "" ? groupProfile.displayName : localAlias }
|
||||
public var fullName: String { get { groupProfile.fullName } }
|
||||
public var image: String? { get { groupProfile.image } }
|
||||
public var localAlias: String { "" }
|
||||
public var chatTags: [Int64]
|
||||
public var chatItemTTL: Int64?
|
||||
public var localAlias: String
|
||||
|
||||
public var isOwner: Bool {
|
||||
return membership.memberRole == .owner && membership.memberCurrent
|
||||
|
@ -1951,7 +1978,8 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable {
|
|||
chatSettings: ChatSettings.defaults,
|
||||
createdAt: .now,
|
||||
updatedAt: .now,
|
||||
chatTags: []
|
||||
chatTags: [],
|
||||
localAlias: ""
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -2008,6 +2036,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 +2131,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 +2139,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 +2170,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 +2190,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 +2214,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 +2625,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 +2725,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 +3047,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 +3192,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 +3367,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 +3436,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 +3707,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 +3715,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 +3725,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 +3747,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 +3759,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 +3819,7 @@ public enum MsgContent: Equatable, Hashable {
|
|||
case preview
|
||||
case image
|
||||
case duration
|
||||
case reason
|
||||
}
|
||||
|
||||
public static func == (lhs: MsgContent, rhs: MsgContent) -> Bool {
|
||||
|
@ -3726,6 +3830,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 +3866,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 +3907,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 +3990,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) {
|
||||
|
@ -4191,45 +4355,53 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable {
|
|||
case day
|
||||
case week
|
||||
case month
|
||||
case year
|
||||
case seconds(_ seconds: Int64)
|
||||
case none
|
||||
|
||||
public static var values: [ChatItemTTL] { [.none, .month, .week, .day] }
|
||||
public static var values: [ChatItemTTL] { [.none, .year, .month, .week, .day] }
|
||||
|
||||
public var id: Self { self }
|
||||
|
||||
public init(_ seconds: Int64?) {
|
||||
public init(_ seconds: Int64) {
|
||||
switch seconds {
|
||||
case 0: self = .none
|
||||
case 86400: self = .day
|
||||
case 7 * 86400: self = .week
|
||||
case 30 * 86400: self = .month
|
||||
case let .some(n): self = .seconds(n)
|
||||
case .none: self = .none
|
||||
case 365 * 86400: self = .year
|
||||
default: self = .seconds(seconds)
|
||||
}
|
||||
}
|
||||
|
||||
public var deleteAfterText: LocalizedStringKey {
|
||||
public var deleteAfterText: String {
|
||||
switch self {
|
||||
case .day: return "1 day"
|
||||
case .week: return "1 week"
|
||||
case .month: return "1 month"
|
||||
case let .seconds(seconds): return "\(seconds) second(s)"
|
||||
case .none: return "never"
|
||||
case .day: return NSLocalizedString("1 day", comment: "delete after time")
|
||||
case .week: return NSLocalizedString("1 week", comment: "delete after time")
|
||||
case .month: return NSLocalizedString("1 month", comment: "delete after time")
|
||||
case .year: return NSLocalizedString("1 year", comment: "delete after time")
|
||||
case let .seconds(seconds): return String.localizedStringWithFormat(NSLocalizedString("%d seconds(s)", comment: "delete after time"), seconds)
|
||||
case .none: return NSLocalizedString("never", comment: "delete after time")
|
||||
}
|
||||
}
|
||||
|
||||
public var seconds: Int64? {
|
||||
public var seconds: Int64 {
|
||||
switch self {
|
||||
case .day: return 86400
|
||||
case .week: return 7 * 86400
|
||||
case .month: return 30 * 86400
|
||||
case .year: return 365 * 86400
|
||||
case let .seconds(seconds): return seconds
|
||||
case .none: return nil
|
||||
case .none: return 0
|
||||
}
|
||||
}
|
||||
|
||||
private var comparisonValue: Int64 {
|
||||
self.seconds ?? Int64.max
|
||||
if self.seconds == 0 {
|
||||
return Int64.max
|
||||
} else {
|
||||
return self.seconds
|
||||
}
|
||||
}
|
||||
|
||||
public static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
|
@ -4237,6 +4409,43 @@ public enum ChatItemTTL: Identifiable, Comparable, Hashable {
|
|||
}
|
||||
}
|
||||
|
||||
public enum ChatTTL: Identifiable, Hashable {
|
||||
case userDefault(ChatItemTTL)
|
||||
case chat(ChatItemTTL)
|
||||
|
||||
public var id: Self { self }
|
||||
|
||||
public var text: String {
|
||||
switch self {
|
||||
case let .chat(ttl): return ttl.deleteAfterText
|
||||
case let .userDefault(ttl): return String.localizedStringWithFormat(
|
||||
NSLocalizedString("default (%@)", comment: "delete after time"),
|
||||
ttl.deleteAfterText)
|
||||
}
|
||||
}
|
||||
|
||||
public var neverExpires: Bool {
|
||||
switch self {
|
||||
case let .chat(ttl): return ttl.seconds == 0
|
||||
case let .userDefault(ttl): return ttl.seconds == 0
|
||||
}
|
||||
}
|
||||
|
||||
public var value: Int64? {
|
||||
switch self {
|
||||
case let .chat(ttl): return ttl.seconds
|
||||
case .userDefault: return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var usingDefault: Bool {
|
||||
switch self {
|
||||
case .userDefault: return true
|
||||
case .chat: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatTag: Decodable, Hashable {
|
||||
public var chatTagId: Int64
|
||||
public var chatTagText: 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)
|
||||
}
|
||||
|
|
|
@ -27,6 +27,14 @@
|
|||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<!-- Allows to query app name and icon that can open specific file type -->
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name="SimplexApp"
|
||||
android:allowBackup="false"
|
||||
|
|
|
@ -32,8 +32,10 @@ object MessagesFetcherWorker {
|
|||
SimplexApp.context.getWorkManagerInstance().enqueueUniqueWork(UNIQUE_WORK_TAG, ExistingWorkPolicy.REPLACE, periodicWorkRequest)
|
||||
}
|
||||
|
||||
fun cancelAll() {
|
||||
Log.d(TAG, "Worker: canceled all tasks")
|
||||
fun cancelAll(withLog: Boolean = true) {
|
||||
if (withLog) {
|
||||
Log.d(TAG, "Worker: canceled all tasks")
|
||||
}
|
||||
SimplexApp.context.getWorkManagerInstance().cancelUniqueWork(UNIQUE_WORK_TAG)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -33,6 +33,7 @@ import chat.simplex.common.views.helpers.*
|
|||
import chat.simplex.common.views.onboarding.OnboardingStage
|
||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.map
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
@ -151,6 +152,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||
* */
|
||||
fun schedulePeriodicServiceRestartWorker() = CoroutineScope(Dispatchers.Default).launch {
|
||||
if (!allowToStartServiceAfterAppExit()) {
|
||||
getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
return@launch
|
||||
}
|
||||
val workerVersion = chatController.appPrefs.autoRestartWorkerVersion.get()
|
||||
|
@ -172,6 +174,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||
|
||||
fun schedulePeriodicWakeUp() = CoroutineScope(Dispatchers.Default).launch {
|
||||
if (!allowToStartPeriodically()) {
|
||||
MessagesFetcherWorker.cancelAll(withLog = false)
|
||||
return@launch
|
||||
}
|
||||
MessagesFetcherWorker.scheduleWork()
|
||||
|
@ -227,7 +230,9 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||
SimplexService.safeStopService()
|
||||
}
|
||||
}
|
||||
|
||||
if (mode != NotificationsMode.SERVICE) {
|
||||
getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
}
|
||||
if (mode != NotificationsMode.PERIODIC) {
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
|
@ -244,6 +249,7 @@ class SimplexApp: Application(), LifecycleEventObserver {
|
|||
}
|
||||
|
||||
override fun androidChatStopped() {
|
||||
getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
SimplexService.safeStopService()
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
}
|
||||
|
|
|
@ -139,6 +139,7 @@ class SimplexService: Service() {
|
|||
if (chatDbStatus != DBMigrationResult.OK) {
|
||||
Log.w(chat.simplex.app.TAG, "SimplexService: problem with the database: $chatDbStatus")
|
||||
showPassphraseNotification(chatDbStatus)
|
||||
androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
safeStopService()
|
||||
return@withLongRunningApi
|
||||
}
|
||||
|
@ -469,53 +470,65 @@ class SimplexService: Service() {
|
|||
)
|
||||
}
|
||||
|
||||
private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert {
|
||||
val ignoreOptimization = {
|
||||
AlertManager.shared.hideAlert()
|
||||
askAboutIgnoringBatteryOptimization()
|
||||
private var showingIgnoreNotification = false
|
||||
private fun showBGServiceNoticeIgnoreOptimization(mode: NotificationsMode, showOffAlert: Boolean) {
|
||||
// that's workaround for situation when the app receives onPause/onResume events multiple times
|
||||
// (for example, after showing system alert for enabling notifications) which triggers showing that alert multiple times
|
||||
if (showingIgnoreNotification) {
|
||||
return
|
||||
}
|
||||
val disableNotifications = {
|
||||
AlertManager.shared.hideAlert()
|
||||
disableNotifications(mode, showOffAlert)
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = disableNotifications,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_bolt),
|
||||
contentDescription =
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
)
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc),
|
||||
Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(annotatedStringResource(MR.strings.turn_off_battery_optimization))
|
||||
|
||||
if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) {
|
||||
Text(annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization),
|
||||
Modifier.padding(top = 8.dp)
|
||||
showingIgnoreNotification = true
|
||||
AlertManager.shared.showAlert {
|
||||
val ignoreOptimization = {
|
||||
AlertManager.shared.hideAlert()
|
||||
showingIgnoreNotification = false
|
||||
askAboutIgnoringBatteryOptimization()
|
||||
}
|
||||
val disableNotifications = {
|
||||
AlertManager.shared.hideAlert()
|
||||
showingIgnoreNotification = false
|
||||
disableNotifications(mode, showOffAlert)
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = disableNotifications,
|
||||
title = {
|
||||
Row {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_bolt),
|
||||
contentDescription =
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.icon_descr_instant_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
)
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) stringResource(MR.strings.service_notifications) else stringResource(MR.strings.periodic_notifications),
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) }
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) }
|
||||
},
|
||||
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
if (mode == NotificationsMode.SERVICE) annotatedStringResource(MR.strings.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery) else annotatedStringResource(MR.strings.periodic_notifications_desc),
|
||||
Modifier.padding(bottom = 8.dp)
|
||||
)
|
||||
Text(annotatedStringResource(MR.strings.turn_off_battery_optimization))
|
||||
|
||||
if (platform.androidIsXiaomiDevice() && (mode == NotificationsMode.PERIODIC || mode == NotificationsMode.SERVICE)) {
|
||||
Text(
|
||||
annotatedStringResource(MR.strings.xiaomi_ignore_battery_optimization),
|
||||
Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = disableNotifications) { Text(stringResource(MR.strings.disable_notifications_button), color = MaterialTheme.colors.error) }
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = ignoreOptimization) { Text(stringResource(MR.strings.turn_off_battery_optimization_button)) }
|
||||
},
|
||||
shape = RoundedCornerShape(corner = CornerSize(25.dp))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBGServiceNoticeSystemRestricted(mode: NotificationsMode, showOffAlert: Boolean) = AlertManager.shared.showAlert {
|
||||
|
@ -681,6 +694,7 @@ class SimplexService: Service() {
|
|||
}
|
||||
ChatController.appPrefs.notificationsMode.set(NotificationsMode.OFF)
|
||||
StartReceiver.toggleReceiver(false)
|
||||
androidAppContext.getWorkManagerInstance().cancelUniqueWork(SimplexService.SERVICE_START_WORKER_WORK_NAME_PERIODIC)
|
||||
MessagesFetcherWorker.cancelAll()
|
||||
safeStopService()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -87,6 +87,9 @@ kotlin {
|
|||
implementation("io.coil-kt:coil-compose:2.6.0")
|
||||
implementation("io.coil-kt:coil-gif:2.6.0")
|
||||
|
||||
// Emojis
|
||||
implementation("androidx.emoji2:emoji2-emojipicker:1.4.0")
|
||||
|
||||
implementation("com.jakewharton:process-phoenix:3.0.0")
|
||||
|
||||
val cameraXVersion = "1.3.4"
|
||||
|
|
|
@ -19,6 +19,8 @@ actual val wallpapersDir: File = File(filesDir.absolutePath + File.separator + "
|
|||
actual val coreTmpDir: File = File(filesDir.absolutePath + File.separator + "temp_files")
|
||||
actual val dbAbsolutePrefixPath: String = dataDir.absolutePath + File.separator + "files"
|
||||
actual val preferencesDir = File(dataDir.absolutePath + File.separator + "shared_prefs")
|
||||
actual val preferencesTmpDir = File(tmpDir, "prefs_tmp")
|
||||
.also { it.deleteRecursively() }
|
||||
|
||||
actual val chatDatabaseFileName: String = "files_chat.db"
|
||||
actual val agentDatabaseFileName: String = "files_agent.db"
|
||||
|
|
|
@ -29,6 +29,7 @@ actual fun LazyColumnWithScrollBar(
|
|||
flingBehavior: FlingBehavior,
|
||||
userScrollEnabled: Boolean,
|
||||
additionalBarOffset: State<Dp>?,
|
||||
additionalTopBar: State<Boolean>,
|
||||
chatBottomBar: State<Boolean>,
|
||||
fillMaxSize: Boolean,
|
||||
content: LazyListScope.() -> Unit
|
||||
|
@ -92,6 +93,7 @@ actual fun LazyColumnWithScrollBarNoAppBar(
|
|||
flingBehavior: FlingBehavior,
|
||||
userScrollEnabled: Boolean,
|
||||
additionalBarOffset: State<Dp>?,
|
||||
additionalTopBar: State<Boolean>,
|
||||
chatBottomBar: State<Boolean>,
|
||||
content: LazyListScope.() -> Unit
|
||||
) {
|
||||
|
|
|
@ -3,19 +3,30 @@ package chat.simplex.common.platform
|
|||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.ClipboardManager
|
||||
import androidx.compose.ui.platform.UriHandler
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import chat.simplex.common.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.File
|
||||
import chat.simplex.res.MR
|
||||
import java.net.URI
|
||||
import kotlin.math.min
|
||||
|
||||
data class OpenDefaultApp(
|
||||
val name: String,
|
||||
val icon: ImageBitmap,
|
||||
val isSystemChooser: Boolean
|
||||
)
|
||||
|
||||
actual fun ClipboardManager.shareText(text: String) {
|
||||
var text = text
|
||||
for (i in 10 downTo 1) {
|
||||
|
@ -37,7 +48,7 @@ actual fun ClipboardManager.shareText(text: String) {
|
|||
}
|
||||
}
|
||||
|
||||
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
|
||||
fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean, useChooser: Boolean = true) {
|
||||
val uri = if (fileSource.cryptoArgs != null) {
|
||||
val tmpFile = File(tmpDir, fileSource.filePath)
|
||||
tmpFile.deleteOnExit()
|
||||
|
@ -67,9 +78,35 @@ fun openOrShareFile(text: String, fileSource: CryptoFile, justOpen: Boolean) {
|
|||
type = mimeType
|
||||
}
|
||||
}
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(shareIntent)
|
||||
if (useChooser) {
|
||||
val shareIntent = Intent.createChooser(sendIntent, null)
|
||||
shareIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(shareIntent)
|
||||
} else {
|
||||
sendIntent.addFlags(FLAG_ACTIVITY_NEW_TASK)
|
||||
androidAppContext.startActivity(sendIntent)
|
||||
}
|
||||
}
|
||||
|
||||
fun queryDefaultAppForExtension(ext: String, encryptedFileUri: URI): OpenDefaultApp? {
|
||||
val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null
|
||||
val openIntent = Intent(Intent.ACTION_VIEW)
|
||||
openIntent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
openIntent.setDataAndType(encryptedFileUri.toUri(), mimeType)
|
||||
val pm = androidAppContext.packageManager
|
||||
//// This method returns the list of apps but no priority, nor default flag
|
||||
// val resInfoList: List<ResolveInfo> = if (Build.VERSION.SDK_INT >= 33) {
|
||||
// pm.queryIntentActivities(openIntent, PackageManager.ResolveInfoFlags.of((PackageManager.MATCH_DEFAULT_ONLY).toLong()))
|
||||
// } else {
|
||||
// pm.queryIntentActivities(openIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
||||
// }.sortedBy { it.priority }
|
||||
// val first = resInfoList.firstOrNull { it.isDefault } ?: resInfoList.firstOrNull() ?: return null
|
||||
val act = pm.resolveActivity(openIntent, PackageManager.MATCH_DEFAULT_ONLY) ?: return null
|
||||
// Log.d(TAG, "Default launch action ${act} ${act.loadLabel(pm)} ${act.activityInfo?.name}")
|
||||
val label = act.loadLabel(pm).toString()
|
||||
val icon = act.loadIcon(pm).toBitmap().asImageBitmap()
|
||||
val chooser = act.activityInfo?.name?.endsWith("ResolverActivity") == true
|
||||
return OpenDefaultApp(label, icon, chooser)
|
||||
}
|
||||
|
||||
actual fun shareFile(text: String, fileSource: CryptoFile) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.common.model.CryptoFile
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.DefaultDropdownMenu
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
actual fun SaveOrOpenFileMenu(
|
||||
showMenu: MutableState<Boolean>,
|
||||
encrypted: Boolean,
|
||||
ext: String?,
|
||||
encryptedUri: URI,
|
||||
fileSource: CryptoFile,
|
||||
saveFile: () -> Unit
|
||||
) {
|
||||
val defaultApp = remember(encryptedUri.toString()) { if (ext != null) queryDefaultAppForExtension(ext, encryptedUri) else null }
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (defaultApp != null) {
|
||||
if (!defaultApp.isSystemChooser) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.open_with_app).format(defaultApp.name),
|
||||
defaultApp.icon,
|
||||
textColor = MaterialTheme.colors.primary,
|
||||
onClick = {
|
||||
openOrShareFile("", fileSource, justOpen = true, useChooser = false)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.open_with_app).format("…"),
|
||||
painterResource(MR.images.ic_open_in_new),
|
||||
color = MaterialTheme.colors.primary,
|
||||
onClick = {
|
||||
openOrShareFile("", fileSource, justOpen = true, useChooser = false)
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
ItemAction(
|
||||
stringResource(MR.strings.save_verb),
|
||||
painterResource(if (encrypted) MR.images.ic_lock_open_right else MR.images.ic_download),
|
||||
color = MaterialTheme.colors.primary,
|
||||
onClick = {
|
||||
saveFile()
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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) }
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.emoji2.emojipicker.EmojiPickerView
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF
|
||||
import chat.simplex.common.views.chat.topPaddingToContent
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
||||
@Composable
|
||||
actual fun ChatTagInput(name: MutableState<String>, showError: State<Boolean>, emoji: MutableState<String?>) {
|
||||
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) {
|
||||
Box(Modifier
|
||||
.clip(shape = CircleShape)
|
||||
.clickable {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
EmojiPicker(close = {
|
||||
close()
|
||||
emoji.value = it
|
||||
})
|
||||
}
|
||||
}
|
||||
.padding(4.dp)
|
||||
) {
|
||||
val emojiValue = emoji.value
|
||||
if (emojiValue != null) {
|
||||
Text(emojiValue)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(MR.images.ic_add_reaction),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
TagListNameTextField(name, showError = showError)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiPicker(close: (String?) -> Unit) {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val topPaddingToContent = topPaddingToContent(false)
|
||||
|
||||
Column (
|
||||
modifier = Modifier.fillMaxSize().navigationBarsPadding().padding(
|
||||
start = DEFAULT_PADDING_HALF,
|
||||
end = DEFAULT_PADDING_HALF,
|
||||
top = if (oneHandUI.value) WindowInsets.statusBars.asPaddingValues().calculateTopPadding() else topPaddingToContent,
|
||||
bottom = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else 0.dp
|
||||
),
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
EmojiPickerView(context).apply {
|
||||
emojiGridColumns = 10
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
setOnEmojiPickedListener { pickedEmoji ->
|
||||
close(pickedEmoji.emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -4,19 +4,31 @@ import android.Manifest
|
|||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import chat.simplex.common.platform.ntfManager
|
||||
import com.google.accompanist.permissions.PermissionStatus
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import com.google.accompanist.permissions.*
|
||||
|
||||
@Composable
|
||||
actual fun SetNotificationsModeAdditions() {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
val notificationsPermissionState = rememberPermissionState(Manifest.permission.POST_NOTIFICATIONS)
|
||||
LaunchedEffect(notificationsPermissionState.status == PermissionStatus.Granted) {
|
||||
if (notificationsPermissionState.status == PermissionStatus.Granted) {
|
||||
ntfManager.androidCreateNtfChannelsMaybeShowAlert()
|
||||
val canAsk = appPrefs.canAskToEnableNotifications.get()
|
||||
if (notificationsPermissionState.status is PermissionStatus.Denied) {
|
||||
if (notificationsPermissionState.status.shouldShowRationale || !canAsk) {
|
||||
if (canAsk) {
|
||||
appPrefs.canAskToEnableNotifications.set(false)
|
||||
}
|
||||
Log.w(TAG, "Notifications are disabled and nobody will ask to enable them")
|
||||
} else {
|
||||
notificationsPermissionState.launchPermissionRequest()
|
||||
}
|
||||
} else {
|
||||
notificationsPermissionState.launchPermissionRequest()
|
||||
if (!canAsk) {
|
||||
// the user allowed notifications in system alert or manually in settings, allow to ask him next time if needed
|
||||
appPrefs.canAskToEnableNotifications.set(true)
|
||||
}
|
||||
ntfManager.androidCreateNtfChannelsMaybeShowAlert()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
|
@ -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
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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,
|
||||
|
@ -80,6 +80,7 @@ class AppPreferences {
|
|||
if (!runServiceInBackground.get()) NotificationsMode.OFF else NotificationsMode.default
|
||||
) { NotificationsMode.values().firstOrNull { it.name == this } }
|
||||
val notificationPreviewMode = mkStrPreference(SHARED_PREFS_NOTIFICATION_PREVIEW_MODE, NotificationPreviewMode.default.name)
|
||||
val canAskToEnableNotifications = mkBoolPreference(SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS, true)
|
||||
val backgroundServiceNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_NOTICE_SHOWN, false)
|
||||
val backgroundServiceBatteryNoticeShown = mkBoolPreference(SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN, false)
|
||||
val autoRestartWorkerVersion = mkIntPreference(SHARED_PREFS_AUTO_RESTART_WORKER_VERSION, 0)
|
||||
|
@ -104,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 {
|
||||
|
@ -358,6 +360,7 @@ class AppPreferences {
|
|||
private const val SHARED_PREFS_RUN_SERVICE_IN_BACKGROUND = "RunServiceInBackground"
|
||||
private const val SHARED_PREFS_NOTIFICATIONS_MODE = "NotificationsMode"
|
||||
private const val SHARED_PREFS_NOTIFICATION_PREVIEW_MODE = "NotificationPreviewMode"
|
||||
private const val SHARED_PREFS_CAN_ASK_TO_ENABLE_NOTIFICATIONS = "CanAskToEnableNotifications"
|
||||
private const val SHARED_PREFS_SERVICE_NOTICE_SHOWN = "BackgroundServiceNoticeShown"
|
||||
private const val SHARED_PREFS_SERVICE_BATTERY_NOTICE_SHOWN = "BackgroundServiceBatteryNoticeShown"
|
||||
private const val SHARED_PREFS_WEBRTC_POLICY_RELAY = "WebrtcPolicyRelay"
|
||||
|
@ -371,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"
|
||||
|
@ -624,6 +628,9 @@ object ChatController {
|
|||
val chats = apiGetChats(rhId)
|
||||
updateChats(chats)
|
||||
}
|
||||
chatModel.userTags.value = apiGetChatTags(rhId).takeIf { hasUser } ?: emptyList()
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
chatModel.updateChatTags(rhId)
|
||||
}
|
||||
|
||||
private fun startReceiver() {
|
||||
|
@ -678,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}")
|
||||
|
@ -879,8 +888,18 @@ object ChatController {
|
|||
return emptyList()
|
||||
}
|
||||
|
||||
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))
|
||||
private suspend fun apiGetChatTags(rh: Long?): List<ChatTag>?{
|
||||
val userId = currentUserId("apiGetChatTags")
|
||||
val r = sendCmd(rh, CC.ApiGetChatTags(userId))
|
||||
|
||||
if (r is CR.ChatTags) return r.userTags
|
||||
Log.e(TAG, "apiGetChatTags bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_loading_chat_tags), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
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) {
|
||||
|
@ -891,6 +910,28 @@ object ChatController {
|
|||
return null
|
||||
}
|
||||
|
||||
suspend fun apiCreateChatTag(rh: Long?, tag: ChatTagData): List<ChatTag>? {
|
||||
val r = sendCmd(rh, CC.ApiCreateChatTag(tag))
|
||||
if (r is CR.ChatTags) return r.userTags
|
||||
Log.e(TAG, "apiCreateChatTag bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_creating_chat_tags), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetChatTags(rh: Long?, type: ChatType, id: Long, tagIds: List<Long>): Pair<List<ChatTag>, List<Long>>? {
|
||||
val r = sendCmd(rh, CC.ApiSetChatTags(type, id, tagIds))
|
||||
if (r is CR.TagsUpdated) return r.userTags to r.chatTags
|
||||
Log.e(TAG, "apiSetChatTags bad response: ${r.responseType} ${r.details}")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.error_updating_chat_tags), "${r.responseType}: ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiDeleteChatTag(rh: Long?, tagId: Long) = sendCommandOkResp(rh, CC.ApiDeleteChatTag(tagId))
|
||||
|
||||
suspend fun apiUpdateChatTag(rh: Long?, tagId: Long, tag: ChatTagData) = sendCommandOkResp(rh, CC.ApiUpdateChatTag(tagId, tag))
|
||||
|
||||
suspend fun apiReorderChatTags(rh: Long?, tagIds: List<Long>) = sendCommandOkResp(rh, CC.ApiReorderChatTags(tagIds))
|
||||
|
||||
suspend fun apiSendMessages(rh: Long?, type: ChatType, id: Long, live: Boolean = false, ttl: Int? = null, composedMessages: List<ComposedMessage>): List<AChatItem>? {
|
||||
val cmd = CC.ApiSendMessages(type, id, live, ttl, composedMessages)
|
||||
return processSendMessageCmd(rh, cmd)
|
||||
|
@ -939,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
|
||||
|
@ -1363,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 -> {
|
||||
|
@ -1450,6 +1511,9 @@ object ChatController {
|
|||
withChats {
|
||||
clearChat(chat.remoteHostId, updatedChatInfo)
|
||||
}
|
||||
withChats(MsgContentTag.Report) {
|
||||
clearChat(chat.remoteHostId, updatedChatInfo)
|
||||
}
|
||||
ntfManager.cancelNotificationsForChat(chat.chatInfo.id)
|
||||
close?.invoke()
|
||||
}
|
||||
|
@ -1498,6 +1562,13 @@ object ChatController {
|
|||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetGroupAlias(rh: Long?, groupId: Long, localAlias: String): GroupInfo? {
|
||||
val r = sendCmd(rh, CC.ApiSetGroupAlias(groupId, localAlias))
|
||||
if (r is CR.GroupAliasUpdated) return r.toGroup
|
||||
Log.e(TAG, "apiSetGroupAlias bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiSetConnectionAlias(rh: Long?, connId: Long, localAlias: String): PendingContactConnection? {
|
||||
val r = sendCmd(rh, CC.ApiSetConnectionAlias(connId, localAlias))
|
||||
if (r is CR.ConnectionAliasUpdated) return r.toConnection
|
||||
|
@ -2355,7 +2426,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()))
|
||||
|
@ -2365,7 +2436,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)
|
||||
|
@ -2377,10 +2448,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
|
||||
}
|
||||
|
@ -2425,9 +2499,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
|
||||
|
@ -2450,6 +2534,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 ->
|
||||
|
@ -2459,13 +2548,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
|
||||
|
@ -2494,6 +2590,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 -> {
|
||||
|
@ -2559,30 +2714,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)) {
|
||||
|
@ -2955,6 +3125,11 @@ object ChatController {
|
|||
val cInfo = aChatItem.chatInfo
|
||||
val cItem = aChatItem.chatItem
|
||||
withChats { upsertChatItem(rh, cInfo, cItem) }
|
||||
withReportsChatsIfOpen {
|
||||
if (cItem.isReport) {
|
||||
upsertChatItem(rh, cInfo, cItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2964,10 +3139,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3007,8 +3186,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()
|
||||
}
|
||||
|
@ -3118,8 +3302,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()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3152,11 +3340,18 @@ sealed class CC {
|
|||
class TestStorageEncryption(val key: String): CC()
|
||||
class ApiSaveSettings(val settings: AppSettings): 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()
|
||||
class ApiSetChatTags(val type: ChatType, val id: Long, val tagIds: List<Long>): CC()
|
||||
class ApiDeleteChatTag(val tagId: Long): 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()
|
||||
|
@ -3223,6 +3418,7 @@ sealed class CC {
|
|||
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
|
||||
class ApiSetContactPrefs(val contactId: Long, val prefs: ChatPreferences): CC()
|
||||
class ApiSetContactAlias(val contactId: Long, val localAlias: String): CC()
|
||||
class ApiSetGroupAlias(val groupId: Long, val localAlias: String): CC()
|
||||
class ApiSetConnectionAlias(val connId: Long, val localAlias: String): CC()
|
||||
class ApiSetUserUIThemes(val userId: Long, val themes: ThemeModeOverrides?): CC()
|
||||
class ApiSetChatUIThemes(val chatId: String, val themes: ThemeModeOverrides?): CC()
|
||||
|
@ -3307,18 +3503,32 @@ sealed class CC {
|
|||
is TestStorageEncryption -> "/db test key $key"
|
||||
is ApiSaveSettings -> "/_save app settings ${json.encodeToString(settings)}"
|
||||
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)
|
||||
val ttlStr = if (ttl != null) "$ttl" else "default"
|
||||
"/_send ${chatRef(type, id)} live=${onOff(live)} ttl=${ttlStr} json $msgs"
|
||||
}
|
||||
is ApiCreateChatTag -> "/_create tag ${json.encodeToString(tag)}"
|
||||
is ApiSetChatTags -> "/_tags ${chatRef(type, id)} ${tagIds.joinToString(",")}"
|
||||
is ApiDeleteChatTag -> "/_delete tag $tagId"
|
||||
is ApiUpdateChatTag -> "/_update tag $tagId ${json.encodeToString(tagData)}"
|
||||
is ApiReorderChatTags -> "/_reorder tags ${tagIds.joinToString(",")}"
|
||||
is ApiCreateChatItems -> {
|
||||
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(",")}"
|
||||
|
@ -3390,6 +3600,7 @@ sealed class CC {
|
|||
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
|
||||
is ApiSetContactPrefs -> "/_set prefs @$contactId ${json.encodeToString(prefs)}"
|
||||
is ApiSetContactAlias -> "/_set alias @$contactId ${localAlias.trim()}"
|
||||
is ApiSetGroupAlias -> "/_set alias #$groupId ${localAlias.trim()}"
|
||||
is ApiSetConnectionAlias -> "/_set alias :$connId ${localAlias.trim()}"
|
||||
is ApiSetUserUIThemes -> "/_set theme user $userId ${if (themes != null) json.encodeToString(themes) else ""}"
|
||||
is ApiSetChatUIThemes -> "/_set theme $chatId ${if (themes != null) json.encodeToString(themes) else ""}"
|
||||
|
@ -3471,11 +3682,18 @@ sealed class CC {
|
|||
is TestStorageEncryption -> "testStorageEncryption"
|
||||
is ApiSaveSettings -> "apiSaveSettings"
|
||||
is ApiGetSettings -> "apiGetSettings"
|
||||
is ApiGetChatTags -> "apiGetChatTags"
|
||||
is ApiGetChats -> "apiGetChats"
|
||||
is ApiGetChat -> "apiGetChat"
|
||||
is ApiGetChatItemInfo -> "apiGetChatItemInfo"
|
||||
is ApiSendMessages -> "apiSendMessages"
|
||||
is ApiCreateChatTag -> "apiCreateChatTag"
|
||||
is ApiSetChatTags -> "apiSetChatTags"
|
||||
is ApiDeleteChatTag -> "apiDeleteChatTag"
|
||||
is ApiUpdateChatTag -> "apiUpdateChatTag"
|
||||
is ApiReorderChatTags -> "apiReorderChatTags"
|
||||
is ApiCreateChatItems -> "apiCreateChatItems"
|
||||
is ApiReportMessage -> "apiReportMessage"
|
||||
is ApiUpdateChatItem -> "apiUpdateChatItem"
|
||||
is ApiDeleteChatItem -> "apiDeleteChatItem"
|
||||
is ApiDeleteMemberChatItem -> "apiDeleteMemberChatItem"
|
||||
|
@ -3542,6 +3760,7 @@ sealed class CC {
|
|||
is ApiUpdateProfile -> "apiUpdateProfile"
|
||||
is ApiSetContactPrefs -> "apiSetContactPrefs"
|
||||
is ApiSetContactAlias -> "apiSetContactAlias"
|
||||
is ApiSetGroupAlias -> "apiSetGroupAlias"
|
||||
is ApiSetConnectionAlias -> "apiSetConnectionAlias"
|
||||
is ApiSetUserUIThemes -> "apiSetUserUIThemes"
|
||||
is ApiSetChatUIThemes -> "apiSetChatUIThemes"
|
||||
|
@ -3657,6 +3876,9 @@ sealed class ChatPagination {
|
|||
@Serializable
|
||||
class ComposedMessage(val fileSource: CryptoFile?, val quotedItemId: Long?, val msgContent: MsgContent)
|
||||
|
||||
@Serializable
|
||||
class ChatTagData(val emoji: String?, val text: String)
|
||||
|
||||
@Serializable
|
||||
class ArchiveConfig(val archivePath: String, val disableCompression: Boolean? = null, val parentTempDirectory: String? = null)
|
||||
|
||||
|
@ -3757,7 +3979,7 @@ data class ServerOperatorConditionsDetail(
|
|||
|
||||
@Serializable()
|
||||
sealed class ConditionsAcceptance {
|
||||
@Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?) : ConditionsAcceptance()
|
||||
@Serializable @SerialName("accepted") data class Accepted(val acceptedAt: Instant?, val autoAccepted: Boolean) : ConditionsAcceptance()
|
||||
@Serializable @SerialName("required") data class Required(val deadline: Instant?) : ConditionsAcceptance()
|
||||
|
||||
val conditionsAccepted: Boolean
|
||||
|
@ -3801,7 +4023,7 @@ data class ServerOperator(
|
|||
tradeName = "SimpleX Chat",
|
||||
legalName = "SimpleX Chat Ltd",
|
||||
serverDomains = listOf("simplex.im"),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(acceptedAt = null, autoAccepted = false),
|
||||
enabled = true,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
|
@ -3883,7 +4105,7 @@ data class UserOperatorServers(
|
|||
tradeName = "",
|
||||
legalName = null,
|
||||
serverDomains = emptyList(),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(null),
|
||||
conditionsAcceptance = ConditionsAcceptance.Accepted(null, autoAccepted = false),
|
||||
enabled = false,
|
||||
smpRoles = ServerRoles(storage = true, proxy = true),
|
||||
xftpRoles = ServerRoles(storage = true, proxy = true)
|
||||
|
@ -5390,6 +5612,7 @@ sealed class CR {
|
|||
@Serializable @SerialName("chatStopped") class ChatStopped: CR()
|
||||
@Serializable @SerialName("apiChats") class ApiChats(val user: UserRef, val chats: List<Chat>): CR()
|
||||
@Serializable @SerialName("apiChat") class ApiChat(val user: UserRef, val chat: Chat, val navInfo: NavigationInfo = NavigationInfo()): CR()
|
||||
@Serializable @SerialName("chatTags") class ChatTags(val user: UserRef, val userTags: List<ChatTag>): CR()
|
||||
@Serializable @SerialName("chatItemInfo") class ApiChatItemInfo(val user: UserRef, val chatItem: AChatItem, val chatItemInfo: ChatItemInfo): CR()
|
||||
@Serializable @SerialName("serverTestResult") class ServerTestResult(val user: UserRef, val testServer: String, val testFailure: ProtocolTestFailure? = null): CR()
|
||||
@Serializable @SerialName("serverOperatorConditions") class ServerOperatorConditions(val conditions: ServerOperatorConditionsDetail): CR()
|
||||
|
@ -5416,6 +5639,7 @@ sealed class CR {
|
|||
@Serializable @SerialName("contactCode") class ContactCode(val user: UserRef, val contact: Contact, val connectionCode: String): CR()
|
||||
@Serializable @SerialName("groupMemberCode") class GroupMemberCode(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember, val connectionCode: String): CR()
|
||||
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
|
||||
@Serializable @SerialName("tagsUpdated") class TagsUpdated(val user: UserRef, val userTags: List<ChatTag>, val chatTags: List<Long>): CR()
|
||||
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
|
||||
|
@ -5431,6 +5655,7 @@ sealed class CR {
|
|||
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
|
||||
@Serializable @SerialName("userPrivacy") class UserPrivacy(val user: User, val updatedUser: User): CR()
|
||||
@Serializable @SerialName("contactAliasUpdated") class ContactAliasUpdated(val user: UserRef, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("groupAliasUpdated") class GroupAliasUpdated(val user: UserRef, val toGroup: GroupInfo): CR()
|
||||
@Serializable @SerialName("connectionAliasUpdated") class ConnectionAliasUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
|
||||
@Serializable @SerialName("contactPrefsUpdated") class ContactPrefsUpdated(val user: UserRef, val fromContact: Contact, val toContact: Contact): CR()
|
||||
@Serializable @SerialName("userContactLink") class UserContactLink(val user: User, val contactLink: UserContactLinkRec): CR()
|
||||
|
@ -5463,6 +5688,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()
|
||||
|
@ -5574,6 +5800,7 @@ sealed class CR {
|
|||
is ChatStopped -> "chatStopped"
|
||||
is ApiChats -> "apiChats"
|
||||
is ApiChat -> "apiChat"
|
||||
is ChatTags -> "chatTags"
|
||||
is ApiChatItemInfo -> "chatItemInfo"
|
||||
is ServerTestResult -> "serverTestResult"
|
||||
is ServerOperatorConditions -> "serverOperatorConditions"
|
||||
|
@ -5600,6 +5827,7 @@ sealed class CR {
|
|||
is ContactCode -> "contactCode"
|
||||
is GroupMemberCode -> "groupMemberCode"
|
||||
is ConnectionVerified -> "connectionVerified"
|
||||
is TagsUpdated -> "tagsUpdated"
|
||||
is Invitation -> "invitation"
|
||||
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
|
||||
is ConnectionUserChanged -> "ConnectionUserChanged"
|
||||
|
@ -5615,6 +5843,7 @@ sealed class CR {
|
|||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
is UserPrivacy -> "userPrivacy"
|
||||
is ContactAliasUpdated -> "contactAliasUpdated"
|
||||
is GroupAliasUpdated -> "groupAliasUpdated"
|
||||
is ConnectionAliasUpdated -> "connectionAliasUpdated"
|
||||
is ContactPrefsUpdated -> "contactPrefsUpdated"
|
||||
is UserContactLink -> "userContactLink"
|
||||
|
@ -5645,6 +5874,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"
|
||||
|
@ -5747,7 +5977,8 @@ 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)}")
|
||||
is ServerOperatorConditions -> "conditions: ${json.encodeToString(conditions)}"
|
||||
|
@ -5774,6 +6005,7 @@ sealed class CR {
|
|||
is ContactCode -> withUser(user, "contact: ${json.encodeToString(contact)}\nconnectionCode: $connectionCode")
|
||||
is GroupMemberCode -> withUser(user, "groupInfo: ${json.encodeToString(groupInfo)}\nmember: ${json.encodeToString(member)}\nconnectionCode: $connectionCode")
|
||||
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
|
||||
is TagsUpdated -> withUser(user, "userTags: ${json.encodeToString(userTags)}\nchatTags: ${json.encodeToString(chatTags)}")
|
||||
is Invitation -> withUser(user, "connReqInvitation: $connReqInvitation\nconnection: $connection")
|
||||
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
|
||||
is ConnectionUserChanged -> withUser(user, "fromConnection: ${json.encodeToString(fromConnection)}\ntoConnection: ${json.encodeToString(toConnection)}\nnewUser: ${json.encodeToString(newUser)}" )
|
||||
|
@ -5789,6 +6021,7 @@ sealed class CR {
|
|||
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
|
||||
is UserPrivacy -> withUser(user, json.encodeToString(updatedUser))
|
||||
is ContactAliasUpdated -> withUser(user, json.encodeToString(toContact))
|
||||
is GroupAliasUpdated -> withUser(user, json.encodeToString(toGroup))
|
||||
is ConnectionAliasUpdated -> withUser(user, json.encodeToString(toConnection))
|
||||
is ContactPrefsUpdated -> withUser(user, "fromContact: $fromContact\ntoContact: \n${json.encodeToString(toContact)}")
|
||||
is UserContactLink -> withUser(user, contactLink.responseDetails)
|
||||
|
@ -5819,6 +6052,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")
|
||||
|
@ -6548,6 +6782,7 @@ sealed class BrokerErrorType {
|
|||
@Serializable @SerialName("TIMEOUT") object TIMEOUT: BrokerErrorType()
|
||||
}
|
||||
|
||||
// ProtocolErrorType
|
||||
@Serializable
|
||||
sealed class SMPErrorType {
|
||||
val string: String get() = when (this) {
|
||||
|
@ -6556,9 +6791,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"
|
||||
|
@ -6569,6 +6805,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()
|
||||
|
@ -6592,6 +6829,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) {
|
||||
|
@ -6667,6 +6920,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"
|
||||
|
@ -6682,6 +6936,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()
|
||||
|
@ -6831,6 +7086,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,
|
||||
|
@ -6839,6 +7101,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,
|
||||
|
@ -6874,6 +7137,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 }
|
||||
|
@ -6920,6 +7184,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) }
|
||||
|
@ -6956,6 +7221,7 @@ data class AppSettings(
|
|||
privacyAskToApproveRelays = true,
|
||||
privacyAcceptImages = true,
|
||||
privacyLinkPreviews = true,
|
||||
privacyChatListOpenLinks = PrivacyChatListOpenLinksMode.ASK,
|
||||
privacyShowChatPreviews = true,
|
||||
privacySaveLastDraft = true,
|
||||
privacyProtectScreen = false,
|
||||
|
@ -6993,6 +7259,7 @@ data class AppSettings(
|
|||
privacyAskToApproveRelays = def.privacyAskToApproveRelays.get(),
|
||||
privacyAcceptImages = def.privacyAcceptImages.get(),
|
||||
privacyLinkPreviews = def.privacyLinkPreviews.get(),
|
||||
privacyChatListOpenLinks = def.privacyChatListOpenLinks.get(),
|
||||
privacyShowChatPreviews = def.privacyShowChatPreviews.get(),
|
||||
privacySaveLastDraft = def.privacySaveLastDraft.get(),
|
||||
privacyProtectScreen = def.privacyProtectScreen.get(),
|
||||
|
|
|
@ -3,7 +3,7 @@ package chat.simplex.common.platform
|
|||
import androidx.compose.runtime.Composable
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import com.charleskorn.kaml.*
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
@ -11,6 +11,8 @@ import java.io.*
|
|||
import java.net.URI
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.StandardCopyOption
|
||||
|
||||
expect val dataDir: File
|
||||
expect val tmpDir: File
|
||||
|
@ -20,6 +22,7 @@ expect val wallpapersDir: File
|
|||
expect val coreTmpDir: File
|
||||
expect val dbAbsolutePrefixPath: String
|
||||
expect val preferencesDir: File
|
||||
expect val preferencesTmpDir: File
|
||||
|
||||
expect val chatDatabaseFileName: String
|
||||
expect val agentDatabaseFileName: String
|
||||
|
@ -142,16 +145,23 @@ fun readThemeOverrides(): List<ThemeOverrides> {
|
|||
}
|
||||
}
|
||||
|
||||
private const val lock = "themesWriter"
|
||||
|
||||
fun writeThemeOverrides(overrides: List<ThemeOverrides>): Boolean =
|
||||
try {
|
||||
File(getPreferenceFilePath("themes.yaml")).outputStream().use {
|
||||
val string = yaml.encodeToString(ThemesFile(themes = overrides))
|
||||
it.bufferedWriter().use { it.write(string) }
|
||||
synchronized(lock) {
|
||||
try {
|
||||
val themesFile = File(getPreferenceFilePath("themes.yaml"))
|
||||
createTmpFileAndDelete(preferencesTmpDir) { tmpFile ->
|
||||
val string = yaml.encodeToString(ThemesFile(themes = overrides))
|
||||
tmpFile.bufferedWriter().use { it.write(string) }
|
||||
themesFile.parentFile.mkdirs()
|
||||
Files.move(tmpFile.toPath(), themesFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error writing themes file: ${e.stackTraceToString()}")
|
||||
false
|
||||
}
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Error while writing themes file: ${e.stackTraceToString()}")
|
||||
false
|
||||
}
|
||||
|
||||
private fun fileReady(file: CIFile, filePath: String) =
|
||||
|
|
|
@ -23,6 +23,7 @@ expect fun LazyColumnWithScrollBar(
|
|||
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
additionalBarOffset: State<Dp>? = null,
|
||||
additionalTopBar: State<Boolean> = remember { mutableStateOf(false) },
|
||||
chatBottomBar: State<Boolean> = remember { mutableStateOf(true) },
|
||||
// by default, this function will include .fillMaxSize() without you doing anything. If you don't need it, pass `false` here
|
||||
// maxSize (at least maxHeight) is needed for blur on appBars to work correctly
|
||||
|
@ -42,6 +43,7 @@ expect fun LazyColumnWithScrollBarNoAppBar(
|
|||
flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(),
|
||||
userScrollEnabled: Boolean = true,
|
||||
additionalBarOffset: State<Dp>? = null,
|
||||
additionalTopBar: State<Boolean> = remember { mutableStateOf(false) },
|
||||
chatBottomBar: State<Boolean> = remember { mutableStateOf(true) },
|
||||
content: LazyListScope.() -> Unit
|
||||
)
|
||||
|
|
|
@ -102,7 +102,9 @@ object ThemeManager {
|
|||
}
|
||||
|
||||
fun applyTheme(theme: String) {
|
||||
appPrefs.currentTheme.set(theme)
|
||||
if (appPrefs.currentTheme.get() != theme) {
|
||||
appPrefs.currentTheme.set(theme)
|
||||
}
|
||||
CurrentColors.value = currentColors(null, null, chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get())
|
||||
platform.androidSetNightModeIfSupported()
|
||||
val c = CurrentColors.value.colors
|
||||
|
|
|
@ -6,11 +6,11 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun SplashView() {
|
||||
fun SplashView(nonTransparent: Boolean = false) {
|
||||
Surface(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
color = MaterialTheme.colors.background,
|
||||
color = if (nonTransparent) MaterialTheme.colors.background.copy(1f) else MaterialTheme.colors.background,
|
||||
contentColor = LocalContentColor.current
|
||||
) {
|
||||
// Image(
|
||||
|
|
|
@ -26,6 +26,7 @@ import chat.simplex.common.platform.*
|
|||
import chat.simplex.common.views.chat.item.CONSOLE_COMPOSE_LAYOUT_ID
|
||||
import chat.simplex.common.views.chat.item.AdaptingBottomPaddingLayout
|
||||
import chat.simplex.common.views.chatlist.NavigationBarBackground
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
|
@ -88,7 +89,7 @@ fun TerminalLayout(
|
|||
.background(MaterialTheme.colors.background)
|
||||
) {
|
||||
Divider()
|
||||
Box(Modifier.padding(horizontal = 8.dp)) {
|
||||
Surface(Modifier.padding(horizontal = 8.dp), color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) {
|
||||
SendMsgView(
|
||||
composeState = composeState,
|
||||
showVoiceRecordIcon = false,
|
||||
|
@ -154,12 +155,12 @@ fun TerminalLog(floating: Boolean, composeViewHeight: State<Dp>) {
|
|||
}
|
||||
}
|
||||
LazyColumnWithScrollBar (
|
||||
reverseLayout = true,
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(
|
||||
top = topPaddingToContent(false),
|
||||
bottom = composeViewHeight.value
|
||||
),
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
additionalBarOffset = composeViewHeight
|
||||
) {
|
||||
items(reversedTerminalItems, key = { item -> item.id to item.createdAtNanos }) { item ->
|
||||
|
|
|
@ -36,6 +36,7 @@ import chat.simplex.common.model.*
|
|||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
|
@ -697,13 +698,19 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
|||
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
)
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val copyNameToClipboard = {
|
||||
clipboard.setText(AnnotatedString(contact.profile.displayName))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}
|
||||
Text(
|
||||
text,
|
||||
inlineContent = inlineContent,
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != contact.profile.displayName) {
|
||||
Text(
|
||||
|
@ -711,7 +718,8 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
|
|||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -724,6 +732,7 @@ fun LocalAliasEditor(
|
|||
center: Boolean = true,
|
||||
leadingIcon: Boolean = false,
|
||||
focus: Boolean = false,
|
||||
isContact: Boolean = true,
|
||||
updateValue: (String) -> Unit
|
||||
) {
|
||||
val state = remember(chatId) {
|
||||
|
@ -740,7 +749,7 @@ fun LocalAliasEditor(
|
|||
state,
|
||||
{
|
||||
Text(
|
||||
generalGetString(MR.strings.text_field_set_contact_placeholder),
|
||||
generalGetString(if (isContact) MR.strings.text_field_set_contact_placeholder else MR.strings.text_field_set_chat_placeholder),
|
||||
textAlign = if (center) TextAlign.Center else TextAlign.Start,
|
||||
color = MaterialTheme.colors.secondary
|
||||
)
|
||||
|
|
|
@ -14,9 +14,10 @@ suspend fun apiLoadSingleMessage(
|
|||
rhId: Long?,
|
||||
chatType: ChatType,
|
||||
apiId: Long,
|
||||
itemId: Long
|
||||
itemId: Long,
|
||||
contentTag: MsgContentTag?,
|
||||
): ChatItem? = coroutineScope {
|
||||
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
|
||||
val (chat, _) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, ChatPagination.Around(itemId, 0), "") ?: return@coroutineScope null
|
||||
chat.chatItems.firstOrNull()
|
||||
}
|
||||
|
||||
|
@ -24,30 +25,37 @@ suspend fun apiLoadMessages(
|
|||
rhId: Long?,
|
||||
chatType: ChatType,
|
||||
apiId: Long,
|
||||
contentTag: MsgContentTag?,
|
||||
pagination: ChatPagination,
|
||||
chatState: ActiveChatState,
|
||||
search: String = "",
|
||||
visibleItemIndexesNonReversed: () -> IntRange = { 0 .. 0 }
|
||||
) = coroutineScope {
|
||||
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, pagination, search) ?: return@coroutineScope
|
||||
val (chat, navInfo) = chatModel.controller.apiGetChat(rhId, chatType, apiId, contentTag, pagination, search) ?: return@coroutineScope
|
||||
// For .initial allow the chatItems to be empty as well as chatModel.chatId to not match this chat because these values become set after .initial finishes
|
||||
if (((chatModel.chatId.value != chat.id || chat.chatItems.isEmpty()) && pagination !is ChatPagination.Initial && pagination !is ChatPagination.Last)
|
||||
|| !isActive) return@coroutineScope
|
||||
|
||||
val chatState = chatModel.chatStateForContent(contentTag)
|
||||
val (splits, unreadAfterItemId, totalAfter, unreadTotal, unreadAfter, unreadAfterNewestLoaded) = chatState
|
||||
val oldItems = chatModel.chatItems.value
|
||||
val oldItems = chatModel.chatItemsForContent(contentTag).value
|
||||
val newItems = SnapshotStateList<ChatItem>()
|
||||
when (pagination) {
|
||||
is ChatPagination.Initial -> {
|
||||
val newSplits = if (chat.chatItems.isNotEmpty() && navInfo.afterTotal > 0) listOf(chat.chatItems.last().id) else emptyList()
|
||||
withChats {
|
||||
if (chatModel.getChat(chat.id) == null) {
|
||||
addChat(chat)
|
||||
if (contentTag == null) {
|
||||
// update main chats, not content tagged
|
||||
withChats {
|
||||
if (getChat(chat.id) == null) {
|
||||
addChat(chat)
|
||||
} else {
|
||||
updateChatInfo(chat.remoteHostId, chat.chatInfo)
|
||||
updateChatStats(chat.remoteHostId, chat.id, chat.chatStats)
|
||||
}
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.replaceAll(chat.chatItems)
|
||||
withChats(contentTag) {
|
||||
chatItemStatuses.clear()
|
||||
chatItems.replaceAll(chat.chatItems)
|
||||
chatModel.chatId.value = chat.chatInfo.id
|
||||
splits.value = newSplits
|
||||
if (chat.chatItems.isNotEmpty()) {
|
||||
|
@ -70,8 +78,8 @@ suspend fun apiLoadMessages(
|
|||
)
|
||||
val insertAt = (indexInCurrentItems - (wasSize - newItems.size) + trimmedIds.size).coerceAtLeast(0)
|
||||
newItems.addAll(insertAt, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats(contentTag) {
|
||||
chatItems.replaceAll(newItems)
|
||||
splits.value = newSplits
|
||||
chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems)
|
||||
}
|
||||
|
@ -89,8 +97,8 @@ suspend fun apiLoadMessages(
|
|||
val indexToAdd = min(indexInCurrentItems + 1, newItems.size)
|
||||
val indexToAddIsLast = indexToAdd == newItems.size
|
||||
newItems.addAll(indexToAdd, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats(contentTag) {
|
||||
chatItems.replaceAll(newItems)
|
||||
splits.value = newSplits
|
||||
chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems)
|
||||
// loading clear bottom area, updating number of unread items after the newest loaded item
|
||||
|
@ -104,8 +112,8 @@ suspend fun apiLoadMessages(
|
|||
val newSplits = removeDuplicatesAndUpperSplits(newItems, chat, splits, visibleItemIndexesNonReversed)
|
||||
// currently, items will always be added on top, which is index 0
|
||||
newItems.addAll(0, chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats(contentTag) {
|
||||
chatItems.replaceAll(newItems)
|
||||
splits.value = listOf(chat.chatItems.last().id) + newSplits
|
||||
unreadAfterItemId.value = chat.chatItems.last().id
|
||||
totalAfter.value = navInfo.afterTotal
|
||||
|
@ -119,8 +127,8 @@ suspend fun apiLoadMessages(
|
|||
newItems.addAll(oldItems)
|
||||
removeDuplicates(newItems, chat)
|
||||
newItems.addAll(chat.chatItems)
|
||||
withContext(Dispatchers.Main) {
|
||||
chatModel.chatItems.replaceAll(newItems)
|
||||
withChats(contentTag) {
|
||||
chatItems.replaceAll(newItems)
|
||||
unreadAfterNewestLoaded.value = 0
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.platform.chatModel
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
|
@ -240,14 +239,13 @@ data class ActiveChatState (
|
|||
}
|
||||
}
|
||||
|
||||
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, listState: LazyListState): IntRange {
|
||||
fun visibleItemIndexesNonReversed(mergedItems: State<MergedItems>, reversedItemsSize: Int, listState: LazyListState): IntRange {
|
||||
val zero = 0 .. 0
|
||||
if (listState.layoutInfo.totalItemsCount == 0) return zero
|
||||
val newest = mergedItems.value.items.getOrNull(listState.firstVisibleItemIndex)?.startIndexInReversedItems
|
||||
val oldest = mergedItems.value.items.getOrNull(listState.layoutInfo.visibleItemsInfo.last().index)?.lastIndexInReversed()
|
||||
if (newest == null || oldest == null) return zero
|
||||
val size = chatModel.chatItems.value.size
|
||||
val range = size - oldest .. size - newest
|
||||
val range = reversedItemsSize - oldest .. reversedItemsSize - newest
|
||||
if (range.first < 0 || range.last < 0) return zero
|
||||
|
||||
// visible items mapped to their underlying data structure which is chatModel.chatItems
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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()
|
||||
|
@ -570,7 +610,7 @@ fun ComposeView(
|
|||
if (remoteHost == null) saveAnimImage(it.uri)
|
||||
else CryptoFile.desktopPlain(it.uri)
|
||||
is UploadContent.Video ->
|
||||
if (remoteHost == null) saveFileFromUri(it.uri)
|
||||
if (remoteHost == null) saveFileFromUri(it.uri, hiddenFileNamePrefix = "video")
|
||||
else CryptoFile.desktopPlain(it.uri)
|
||||
}
|
||||
if (file != null) {
|
||||
|
@ -796,7 +836,7 @@ fun ComposeView(
|
|||
|
||||
fun editPrevMessage() {
|
||||
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
|
||||
val lastEditable = chatModel.chatItems.value.findLast { it.meta.editable }
|
||||
val lastEditable = chatModel.chatItemsForContent(null).value.findLast { it.meta.editable }
|
||||
if (lastEditable != null) {
|
||||
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
|
||||
}
|
||||
|
@ -833,14 +873,33 @@ fun ComposeView(
|
|||
|
||||
@Composable
|
||||
fun MsgNotAllowedView(reason: String, icon: Painter) {
|
||||
val color = MaterialTheme.appColors.receivedMessage
|
||||
Row(Modifier.padding(top = 5.dp).fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
|
||||
val color = MaterialTheme.appColors.receivedQuote
|
||||
Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(icon, null, tint = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.width(DEFAULT_PADDING_HALF))
|
||||
Text(reason, fontStyle = FontStyle.Italic)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReportReasonView(reason: ReportReason) {
|
||||
val reportText = when (reason) {
|
||||
is ReportReason.Spam -> generalGetString(MR.strings.report_compose_reason_header_spam)
|
||||
is ReportReason.Illegal -> generalGetString(MR.strings.report_compose_reason_header_illegal)
|
||||
is ReportReason.Profile -> generalGetString(MR.strings.report_compose_reason_header_profile)
|
||||
is ReportReason.Community -> generalGetString(MR.strings.report_compose_reason_header_community)
|
||||
is ReportReason.Other -> generalGetString(MR.strings.report_compose_reason_header_other)
|
||||
is ReportReason.Unknown -> null // should never happen
|
||||
}
|
||||
|
||||
if (reportText != null) {
|
||||
val color = MaterialTheme.appColors.receivedQuote
|
||||
Row(Modifier.fillMaxWidth().background(color).padding(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF * 1.5f), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(reportText, fontStyle = FontStyle.Italic, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun contextItemView() {
|
||||
when (val contextItem = composeState.value.contextItem) {
|
||||
|
@ -854,6 +913,9 @@ fun ComposeView(
|
|||
is ComposeContextItem.ForwardingItems -> ContextItemView(contextItem.chatItems, painterResource(MR.images.ic_forward), showSender = false, chatType = chat.chatInfo.chatType) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
is ComposeContextItem.ReportedItem -> ContextItemView(listOf(contextItem.chatItem), painterResource(MR.images.ic_flag), chatType = chat.chatInfo.chatType, contextIconColor = Color.Red) {
|
||||
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.NoContextItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -891,6 +953,10 @@ fun ComposeView(
|
|||
if (nextSendGrpInv.value) {
|
||||
ComposeContextInvitingContactMemberView()
|
||||
}
|
||||
val ctx = composeState.value.contextItem
|
||||
if (ctx is ComposeContextItem.ReportedItem) {
|
||||
ReportReasonView(ctx.reason)
|
||||
}
|
||||
val simplexLinkProhibited = hasSimplexLink.value && !chat.groupFeatureEnabled(GroupFeature.SimplexLinks)
|
||||
val fileProhibited = composeState.value.attachmentPreview && !chat.groupFeatureEnabled(GroupFeature.Files)
|
||||
val voiceProhibited = composeState.value.preview is ComposePreview.VoicePreview && !chat.chatInfo.featureEnabled(ChatFeature.Voice)
|
||||
|
@ -918,154 +984,153 @@ fun ComposeView(
|
|||
}
|
||||
}
|
||||
}
|
||||
Box(Modifier.background(MaterialTheme.colors.background)) {
|
||||
Divider()
|
||||
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
|
||||
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
|
||||
val attachmentClicked = if (isGroupAndProhibitedFiles) {
|
||||
{
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.files_and_media_prohibited),
|
||||
text = generalGetString(MR.strings.only_owners_can_enable_files_and_media)
|
||||
Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) {
|
||||
Divider()
|
||||
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
|
||||
val isGroupAndProhibitedFiles = chat.chatInfo is ChatInfo.Group && !chat.chatInfo.groupInfo.fullGroupPreferences.files.on(chat.chatInfo.groupInfo.membership)
|
||||
val attachmentClicked = if (isGroupAndProhibitedFiles) {
|
||||
{
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.files_and_media_prohibited),
|
||||
text = generalGetString(MR.strings.only_owners_can_enable_files_and_media)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showChooseAttachment
|
||||
}
|
||||
val attachmentEnabled =
|
||||
!composeState.value.attachmentDisabled
|
||||
&& sendMsgEnabled.value
|
||||
&& userCanSend.value
|
||||
&& !isGroupAndProhibitedFiles
|
||||
&& !nextSendGrpInv.value
|
||||
IconButton(
|
||||
attachmentClicked,
|
||||
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
|
||||
enabled = attachmentEnabled
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_attach_file_filled_500),
|
||||
contentDescription = stringResource(MR.strings.attach),
|
||||
tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
showChooseAttachment
|
||||
}
|
||||
val attachmentEnabled =
|
||||
!composeState.value.attachmentDisabled
|
||||
&& sendMsgEnabled.value
|
||||
&& userCanSend.value
|
||||
&& !isGroupAndProhibitedFiles
|
||||
&& !nextSendGrpInv.value
|
||||
IconButton(
|
||||
attachmentClicked,
|
||||
Modifier.padding(start = 3.dp, end = 1.dp, bottom = if (appPlatform.isAndroid) 2.sp.toDp() else 5.sp.toDp() * fontSizeSqrtMultiplier),
|
||||
enabled = attachmentEnabled
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_attach_file_filled_500),
|
||||
contentDescription = stringResource(MR.strings.attach),
|
||||
tint = if (attachmentEnabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.clip(CircleShape)
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
|
||||
LaunchedEffect(allowedVoiceByPrefs) {
|
||||
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
// Voice was disabled right when this user records it, just cancel it
|
||||
cancelVoice()
|
||||
}
|
||||
}
|
||||
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||
contactPreference.allow == FeatureAllowed.YES
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { recState.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
when (it) {
|
||||
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
|
||||
is RecordingState.Finished -> if (it.durationMs > 300) {
|
||||
onAudioAdded(it.filePath, it.durationMs, true)
|
||||
} else {
|
||||
cancelVoice()
|
||||
}
|
||||
is RecordingState.NotStarted -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) {
|
||||
if (!chat.chatInfo.userCanSend) {
|
||||
clearCurrentDraft()
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
|
||||
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
|
||||
val cs = composeState.value
|
||||
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||
sendMessage(null)
|
||||
resetLinkPreview()
|
||||
clearPrevDraft(prevChatId)
|
||||
deleteUnusedFiles()
|
||||
} else if (cs.inProgress) {
|
||||
clearPrevDraft(prevChatId)
|
||||
} else if (!cs.empty) {
|
||||
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
|
||||
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
|
||||
}
|
||||
if (saveLastDraft) {
|
||||
chatModel.draft.value = composeState.value
|
||||
chatModel.draftChatId.value = prevChatId
|
||||
}
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
|
||||
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
clearPrevDraft(prevChatId)
|
||||
deleteUnusedFiles()
|
||||
}
|
||||
chatModel.removeLiveDummy()
|
||||
CIFile.cachedRemoteFileRequests.clear()
|
||||
}
|
||||
if (appPlatform.isDesktop) {
|
||||
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
|
||||
chatModel.draft.value = composeState.value
|
||||
chatModel.draftChatId.value = chat.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
|
||||
val sendButtonColor =
|
||||
if (chat.chatInfo.incognito)
|
||||
if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
|
||||
else MaterialTheme.colors.primary
|
||||
SendMsgView(
|
||||
composeState,
|
||||
showVoiceRecordIcon = true,
|
||||
recState,
|
||||
chat.chatInfo is ChatInfo.Direct,
|
||||
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
|
||||
sendMsgEnabled = sendMsgEnabled.value,
|
||||
sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited),
|
||||
nextSendGrpInv = nextSendGrpInv.value,
|
||||
needToAllowVoiceToContact,
|
||||
allowedVoiceByPrefs,
|
||||
allowVoiceToContact = ::allowVoiceToContact,
|
||||
userIsObserver = userIsObserver.value,
|
||||
userCanSend = userCanSend.value,
|
||||
sendButtonColor = sendButtonColor,
|
||||
timedMessageAllowed = timedMessageAllowed,
|
||||
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
|
||||
placeholder = composeState.value.placeholder,
|
||||
sendMessage = { ttl ->
|
||||
sendMessage(ttl)
|
||||
resetLinkPreview()
|
||||
},
|
||||
sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null,
|
||||
updateLiveMessage = ::updateLiveMessage,
|
||||
cancelLiveMessage = {
|
||||
composeState.value = composeState.value.copy(liveMessage = null)
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
editPrevMessage = ::editPrevMessage,
|
||||
onFilesPasted = { composeState.onFilesAttached(it) },
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
val allowedVoiceByPrefs = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.Voice) }
|
||||
LaunchedEffect(allowedVoiceByPrefs) {
|
||||
if (!allowedVoiceByPrefs && composeState.value.preview is ComposePreview.VoicePreview) {
|
||||
// Voice was disabled right when this user records it, just cancel it
|
||||
cancelVoice()
|
||||
}
|
||||
}
|
||||
val needToAllowVoiceToContact = remember(chat.chatInfo) {
|
||||
chat.chatInfo is ChatInfo.Direct && with(chat.chatInfo.contact.mergedPreferences.voice) {
|
||||
((userPreference as? ContactUserPref.User)?.preference?.allow == FeatureAllowed.NO || (userPreference as? ContactUserPref.Contact)?.preference?.allow == FeatureAllowed.NO) &&
|
||||
contactPreference.allow == FeatureAllowed.YES
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { recState.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
when (it) {
|
||||
is RecordingState.Started -> onAudioAdded(it.filePath, it.progressMs, false)
|
||||
is RecordingState.Finished -> if (it.durationMs > 300) {
|
||||
onAudioAdded(it.filePath, it.durationMs, true)
|
||||
} else {
|
||||
cancelVoice()
|
||||
}
|
||||
is RecordingState.NotStarted -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(rememberUpdatedState(chat.chatInfo.userCanSend).value) {
|
||||
if (!chat.chatInfo.userCanSend) {
|
||||
clearCurrentDraft()
|
||||
clearState()
|
||||
}
|
||||
}
|
||||
|
||||
KeyChangeEffect(chatModel.chatId.value) { prevChatId ->
|
||||
val cs = composeState.value
|
||||
if (cs.liveMessage != null && (cs.message.isNotEmpty() || cs.liveMessage.sent)) {
|
||||
sendMessage(null)
|
||||
resetLinkPreview()
|
||||
clearPrevDraft(prevChatId)
|
||||
deleteUnusedFiles()
|
||||
} else if (cs.inProgress) {
|
||||
clearPrevDraft(prevChatId)
|
||||
} else if (!cs.empty) {
|
||||
if (cs.preview is ComposePreview.VoicePreview && !cs.preview.finished) {
|
||||
composeState.value = cs.copy(preview = cs.preview.copy(finished = true))
|
||||
}
|
||||
if (saveLastDraft) {
|
||||
chatModel.draft.value = composeState.value
|
||||
chatModel.draftChatId.value = prevChatId
|
||||
}
|
||||
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
} else if (chatModel.draftChatId.value == chatModel.chatId.value && chatModel.draft.value != null) {
|
||||
composeState.value = chatModel.draft.value ?: ComposeState(useLinkPreviews = useLinkPreviews)
|
||||
} else {
|
||||
clearPrevDraft(prevChatId)
|
||||
deleteUnusedFiles()
|
||||
}
|
||||
chatModel.removeLiveDummy()
|
||||
CIFile.cachedRemoteFileRequests.clear()
|
||||
}
|
||||
if (appPlatform.isDesktop) {
|
||||
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
if (chatModel.sharedContent.value is SharedContent.Forward && saveLastDraft && !composeState.value.empty) {
|
||||
chatModel.draft.value = composeState.value
|
||||
chatModel.draftChatId.value = chat.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val timedMessageAllowed = remember(chat.chatInfo) { chat.chatInfo.featureEnabled(ChatFeature.TimedMessages) }
|
||||
val sendButtonColor =
|
||||
if (chat.chatInfo.incognito)
|
||||
if (isInDarkTheme()) Indigo else Indigo.copy(alpha = 0.7F)
|
||||
else MaterialTheme.colors.primary
|
||||
SendMsgView(
|
||||
composeState,
|
||||
showVoiceRecordIcon = true,
|
||||
recState,
|
||||
chat.chatInfo is ChatInfo.Direct,
|
||||
liveMessageAlertShown = chatModel.controller.appPrefs.liveMessageAlertShown,
|
||||
sendMsgEnabled = sendMsgEnabled.value,
|
||||
sendButtonEnabled = sendMsgEnabled.value && !(simplexLinkProhibited || fileProhibited || voiceProhibited),
|
||||
nextSendGrpInv = nextSendGrpInv.value,
|
||||
needToAllowVoiceToContact,
|
||||
allowedVoiceByPrefs,
|
||||
allowVoiceToContact = ::allowVoiceToContact,
|
||||
userIsObserver = userIsObserver.value,
|
||||
userCanSend = userCanSend.value,
|
||||
sendButtonColor = sendButtonColor,
|
||||
timedMessageAllowed = timedMessageAllowed,
|
||||
customDisappearingMessageTimePref = chatModel.controller.appPrefs.customDisappearingMessageTime,
|
||||
placeholder = stringResource(MR.strings.compose_message_placeholder),
|
||||
sendMessage = { ttl ->
|
||||
sendMessage(ttl)
|
||||
resetLinkPreview()
|
||||
},
|
||||
sendLiveMessage = if (chat.chatInfo.chatType != ChatType.Local) ::sendLiveMessage else null,
|
||||
updateLiveMessage = ::updateLiveMessage,
|
||||
cancelLiveMessage = {
|
||||
composeState.value = composeState.value.copy(liveMessage = null)
|
||||
chatModel.removeLiveDummy()
|
||||
},
|
||||
editPrevMessage = ::editPrevMessage,
|
||||
onFilesPasted = { composeState.onFilesAttached(it) },
|
||||
onMessageChange = ::onMessageChange,
|
||||
textStyle = textStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import androidx.compose.desktop.ui.tooling.preview.Preview
|
|||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -31,6 +32,7 @@ fun ContextItemView(
|
|||
contextIcon: Painter,
|
||||
showSender: Boolean = true,
|
||||
chatType: ChatType,
|
||||
contextIconColor: Color = MaterialTheme.colors.secondary,
|
||||
cancelContextItem: () -> Unit,
|
||||
) {
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
|
@ -85,7 +87,6 @@ fun ContextItemView(
|
|||
|
||||
Row(
|
||||
Modifier
|
||||
.padding(top = 8.dp)
|
||||
.background(if (sent) sentColor else receivedColor),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
|
@ -103,8 +104,8 @@ fun ContextItemView(
|
|||
.height(20.dp)
|
||||
.width(20.dp),
|
||||
contentDescription = stringResource(MR.strings.icon_descr_context),
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
)
|
||||
tint = contextIconColor,
|
||||
)
|
||||
|
||||
if (contextItems.count() == 1) {
|
||||
val contextItem = contextItems[0]
|
||||
|
|
|
@ -21,11 +21,10 @@ import chat.simplex.res.MR
|
|||
import dev.icerock.moko.resources.compose.painterResource
|
||||
|
||||
@Composable
|
||||
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>) {
|
||||
fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>, onTop: Boolean) {
|
||||
val onBackClicked = { selectedChatItems.value = null }
|
||||
BackHandler(onBack = onBackClicked)
|
||||
val count = selectedChatItems.value?.size ?: 0
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
DefaultAppBar(
|
||||
navigationButton = { NavigationButtonClose(onButtonClicked = onBackClicked) },
|
||||
title = {
|
||||
|
@ -41,7 +40,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>
|
|||
)
|
||||
},
|
||||
onTitleClick = null,
|
||||
onTop = !oneHandUI.value,
|
||||
onTop = onTop,
|
||||
onSearchValueChanged = {},
|
||||
)
|
||||
}
|
||||
|
@ -49,7 +48,7 @@ fun BoxScope.SelectedItemsTopToolbar(selectedChatItems: MutableState<Set<Long>?>
|
|||
@Composable
|
||||
fun SelectedItemsBottomToolbar(
|
||||
chatInfo: ChatInfo,
|
||||
chatItems: List<ChatItem>,
|
||||
reversedChatItems: State<List<ChatItem>>,
|
||||
selectedChatItems: MutableState<Set<Long>?>,
|
||||
deleteItems: (Boolean) -> Unit, // Boolean - delete for everyone is possible
|
||||
moderateItems: () -> Unit,
|
||||
|
@ -108,8 +107,8 @@ fun SelectedItemsBottomToolbar(
|
|||
}
|
||||
Divider(Modifier.align(Alignment.TopStart))
|
||||
}
|
||||
LaunchedEffect(chatInfo, chatItems, selectedChatItems.value) {
|
||||
recheckItems(chatInfo, chatItems, selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
|
||||
LaunchedEffect(chatInfo, reversedChatItems.value, selectedChatItems.value) {
|
||||
recheckItems(chatInfo, reversedChatItems.value.asReversed(), selectedChatItems, deleteEnabled, deleteForEveryoneEnabled, canModerate, moderateEnabled, forwardEnabled, deleteCountProhibited, forwardCountProhibited)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,10 +137,10 @@ private fun recheckItems(chatInfo: ChatInfo,
|
|||
for (ci in chatItems) {
|
||||
if (selected.contains(ci.id)) {
|
||||
rDeleteEnabled = rDeleteEnabled && ci.canBeDeletedForSelf
|
||||
rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote
|
||||
rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd
|
||||
rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null
|
||||
rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy
|
||||
rDeleteForEveryoneEnabled = rDeleteForEveryoneEnabled && ci.meta.deletable && !ci.localNote && !ci.isReport
|
||||
rOnlyOwnGroupItems = rOnlyOwnGroupItems && ci.chatDir is CIDirection.GroupSnd && !ci.isReport
|
||||
rModerateEnabled = rModerateEnabled && ci.content.msgContent != null && ci.memberToModerate(chatInfo) != null && !ci.isReport
|
||||
rForwardEnabled = rForwardEnabled && ci.content.msgContent != null && ci.meta.itemDeleted == null && !ci.isLiveDummy && !ci.isReport
|
||||
rSelectedChatItems.add(ci.id) // we are collecting new selected items here to account for any changes in chat items list
|
||||
}
|
||||
}
|
||||
|
|
|
@ -74,7 +74,7 @@ fun SendMsgView(
|
|||
}
|
||||
}
|
||||
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
|
||||
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
|
||||
!composeState.value.forwarding && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) && (cs.contextItem !is ComposeContextItem.ReportedItem)
|
||||
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
|
||||
val sendMsgButtonDisabled = !sendMsgEnabled || !cs.sendEnabled() ||
|
||||
(!allowedVoiceByPrefs && cs.preview is ComposePreview.VoicePreview) ||
|
||||
|
@ -125,6 +125,9 @@ fun SendMsgView(
|
|||
}
|
||||
when {
|
||||
progressByTimeout -> ProgressIndicator()
|
||||
cs.contextItem is ComposeContextItem.ReportedItem -> {
|
||||
SendMsgButton(painterResource(MR.images.ic_check_filled), sendButtonSize, sendButtonAlpha, sendButtonColor, !sendMsgButtonDisabled, sendMessage)
|
||||
}
|
||||
showVoiceButton && sendMsgEnabled -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val stopRecOnNextClick = remember { mutableStateOf(false) }
|
||||
|
|
|
@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.ChatInfoToolbarTitle
|
||||
import chat.simplex.common.views.helpers.*
|
||||
|
@ -64,6 +65,9 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
|
|||
withChats {
|
||||
upsertGroupMember(rhId, groupInfo, member)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, groupInfo, member)
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
@ -83,7 +87,7 @@ fun AddGroupMembersView(rhId: Long?, groupInfo: GroupInfo, creatingGroup: Boolea
|
|||
|
||||
fun getContactsToAdd(chatModel: ChatModel, search: String): List<Contact> {
|
||||
val s = search.trim().lowercase()
|
||||
val memberContactIds = chatModel.groupMembers
|
||||
val memberContactIds = chatModel.groupMembers.value
|
||||
.filter { it.memberCurrent }
|
||||
.mapNotNull { it.memberContactId }
|
||||
return chatModel.chats.value
|
||||
|
@ -209,8 +213,8 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val values = GroupMemberRole.values()
|
||||
.filter { it <= groupInfo.membership.memberRole && it != GroupMemberRole.Author }
|
||||
val values = GroupMemberRole.selectableRoles
|
||||
.filter { it <= groupInfo.membership.memberRole }
|
||||
.map { it to it.text }
|
||||
ExposedDropDownSettingRow(
|
||||
generalGetString(MR.strings.new_member_role),
|
||||
|
|
|
@ -9,6 +9,7 @@ import SectionSpacer
|
|||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.material.*
|
||||
|
@ -17,6 +18,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
|
@ -27,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.*
|
||||
|
@ -37,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 }
|
||||
|
@ -51,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,
|
||||
|
@ -61,14 +66,18 @@ 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,
|
||||
onLocalAliasChanged = { setGroupAlias(chat, it, chatModel) },
|
||||
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)
|
||||
}
|
||||
|
@ -192,6 +201,9 @@ private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMembe
|
|||
withChats {
|
||||
upsertGroupMember(rhId, groupInfo, updatedMember)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, groupInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -275,7 +287,9 @@ fun ModalData.GroupChatInfoLayout(
|
|||
setSendReceipts: (SendReceipts) -> Unit,
|
||||
members: List<GroupMember>,
|
||||
developerTools: Boolean,
|
||||
onLocalAliasChanged: (String) -> Unit,
|
||||
groupLink: String?,
|
||||
scrollToItemId: MutableState<Long?>,
|
||||
addMembers: () -> Unit,
|
||||
showMemberInfo: (GroupMember) -> Unit,
|
||||
editGroupProfile: () -> Unit,
|
||||
|
@ -303,20 +317,23 @@ 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(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
GroupChatInfoHeader(chat.chatInfo)
|
||||
GroupChatInfoHeader(chat.chatInfo, groupInfo)
|
||||
}
|
||||
|
||||
LocalAliasEditor(chat.id, groupInfo.localAlias, isContact = false, updateValue = onLocalAliasChanged)
|
||||
|
||||
SectionSpacer()
|
||||
|
||||
Box(
|
||||
|
@ -352,6 +369,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 {
|
||||
|
@ -440,26 +464,33 @@ fun ModalData.GroupChatInfoLayout(
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupChatInfoHeader(cInfo: ChatInfo) {
|
||||
private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) {
|
||||
Column(
|
||||
Modifier.padding(horizontal = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val copyNameToClipboard = {
|
||||
clipboard.setText(AnnotatedString(groupInfo.groupProfile.displayName))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}
|
||||
Text(
|
||||
cInfo.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
groupInfo.groupProfile.displayName, style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
|
||||
)
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName) {
|
||||
if (cInfo.fullName != "" && cInfo.fullName != cInfo.displayName && cInfo.fullName != groupInfo.groupProfile.displayName) {
|
||||
Text(
|
||||
cInfo.fullName, style = MaterialTheme.typography.h2,
|
||||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 8,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -474,6 +505,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 {
|
||||
|
@ -707,6 +747,15 @@ private fun SearchRowView(
|
|||
}
|
||||
}
|
||||
|
||||
private fun setGroupAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi {
|
||||
val chatRh = chat.remoteHostId
|
||||
chatModel.controller.apiSetGroupAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let {
|
||||
withChats {
|
||||
updateGroup(chatRh, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun PreviewGroupChatInfoLayout() {
|
||||
|
@ -723,7 +772,9 @@ fun PreviewGroupChatInfoLayout() {
|
|||
setSendReceipts = {},
|
||||
members = listOf(GroupMember.sampleData, GroupMember.sampleData, GroupMember.sampleData),
|
||||
developerTools = false,
|
||||
onLocalAliasChanged = {},
|
||||
groupLink = null,
|
||||
scrollToItemId = remember { mutableStateOf(null) },
|
||||
addMembers = {}, showMemberInfo = {}, editGroupProfile = {}, addOrEditWelcomeMessage = {}, openPreferences = {}, deleteGroup = {}, clearChat = {}, leaveGroup = {}, manageGroupLink = {}, onSearchClicked = {},
|
||||
)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import SectionSpacer
|
|||
import SectionTextFooter
|
||||
import SectionView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
import androidx.compose.foundation.text.appendInlineContent
|
||||
|
@ -27,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.*
|
||||
|
@ -64,6 +66,9 @@ fun GroupMemberInfoView(
|
|||
withChats {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
|
@ -82,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()
|
||||
}
|
||||
|
@ -97,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())
|
||||
}
|
||||
|
@ -141,6 +146,9 @@ fun GroupMemberInfoView(
|
|||
withChats {
|
||||
upsertGroupMember(rhId, groupInfo, mem)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, groupInfo, mem)
|
||||
}
|
||||
}.onFailure {
|
||||
newRole.value = prevValue
|
||||
}
|
||||
|
@ -156,6 +164,9 @@ fun GroupMemberInfoView(
|
|||
withChats {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
|
@ -170,6 +181,9 @@ fun GroupMemberInfoView(
|
|||
withChats {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
|
@ -187,6 +201,9 @@ fun GroupMemberInfoView(
|
|||
withChats {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
updateGroupMemberConnectionStats(rhId, groupInfo, r.first, r.second)
|
||||
}
|
||||
close.invoke()
|
||||
}
|
||||
}
|
||||
|
@ -202,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
|
||||
}
|
||||
|
@ -245,6 +262,9 @@ fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c
|
|||
withChats {
|
||||
upsertGroupMember(rhId, groupInfo, removedMember)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, groupInfo, removedMember)
|
||||
}
|
||||
}
|
||||
close?.invoke()
|
||||
}
|
||||
|
@ -537,13 +557,19 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
|||
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
)
|
||||
val clipboard = LocalClipboardManager.current
|
||||
val copyNameToClipboard = {
|
||||
clipboard.setText(AnnotatedString(member.displayName))
|
||||
showToast(generalGetString(MR.strings.copied))
|
||||
}
|
||||
Text(
|
||||
text,
|
||||
inlineContent = inlineContent,
|
||||
style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal),
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
|
||||
)
|
||||
if (member.fullName != "" && member.fullName != member.displayName) {
|
||||
Text(
|
||||
|
@ -551,7 +577,8 @@ fun GroupMemberInfoHeader(member: GroupMember) {
|
|||
color = MaterialTheme.colors.onBackground,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.combinedClickable(onClick = copyNameToClipboard, onLongClick = copyNameToClipboard).onRightClick(copyNameToClipboard)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -745,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -778,6 +808,9 @@ fun blockMemberForAll(rhId: Long?, gInfo: GroupInfo, member: GroupMember, blocke
|
|||
withChats {
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
upsertGroupMember(rhId, gInfo, updatedMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
|
|||
updateGroup(rhId, g)
|
||||
currentPreferences = preferences
|
||||
}
|
||||
withChats {
|
||||
updateGroup(rhId, g)
|
||||
}
|
||||
}
|
||||
afterSave()
|
||||
}
|
||||
|
|
|
@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.*
|
||||
|
|
|
@ -0,0 +1,106 @@
|
|||
package chat.simplex.common.views.chat.group
|
||||
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.chatlist.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.flow.*
|
||||
|
||||
val LocalContentTag: ProvidableCompositionLocal<MsgContentTag?> = staticCompositionLocalOf { null }
|
||||
|
||||
data class GroupReports(
|
||||
val reportsCount: Int,
|
||||
val reportsView: Boolean,
|
||||
) {
|
||||
val showBar: Boolean = reportsCount > 0 && !reportsView
|
||||
|
||||
fun toContentTag(): MsgContentTag? {
|
||||
if (!reportsView) return null
|
||||
return MsgContentTag.Report
|
||||
}
|
||||
|
||||
val contentTag: MsgContentTag? = if (!reportsView) null else MsgContentTag.Report
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupReportsView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>) {
|
||||
ChatView(staleChatId, reportsView = true, scrollToItemId, onComposed = {})
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupReportsAppBar(
|
||||
groupReports: State<GroupReports>,
|
||||
close: () -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit
|
||||
) {
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val showSearch = rememberSaveable { mutableStateOf(false) }
|
||||
val onBackClicked = {
|
||||
if (!showSearch.value) {
|
||||
close()
|
||||
} else {
|
||||
onSearchValueChanged("")
|
||||
showSearch.value = false
|
||||
}
|
||||
}
|
||||
BackHandler(onBack = onBackClicked)
|
||||
DefaultAppBar(
|
||||
navigationButton = { NavigationButtonBack(onBackClicked) },
|
||||
fixedTitleText = stringResource(MR.strings.group_reports_member_reports),
|
||||
onTitleClick = null,
|
||||
onTop = !oneHandUI.value,
|
||||
showSearch = showSearch.value,
|
||||
onSearchValueChanged = onSearchValueChanged,
|
||||
buttons = {
|
||||
IconButton({ showSearch.value = true }) {
|
||||
Icon(painterResource(MR.images.ic_search), stringResource(MR.strings.search_verb), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
)
|
||||
ItemsReload(groupReports)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemsReload(groupReports: State<GroupReports>) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.filterNotNull()
|
||||
.map { chatModel.getChat(it) }
|
||||
.filterNotNull()
|
||||
.filter { it.chatInfo is ChatInfo.Group }
|
||||
.collect { chat ->
|
||||
reloadItems(chat, groupReports)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun showGroupReportsView(staleChatId: State<String?>, scrollToItemId: MutableState<Long?>, chatInfo: ChatInfo) {
|
||||
openChat(chatModel.remoteHostId(), chatInfo, MsgContentTag.Report)
|
||||
ModalManager.end.showCustomModal(true, id = ModalViewId.GROUP_REPORTS) { close ->
|
||||
ModalView({}, showAppBar = false) {
|
||||
val chatInfo = remember { derivedStateOf { chatModel.chats.value.firstOrNull { it.id == chatModel.chatId.value }?.chatInfo } }.value
|
||||
if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) {
|
||||
GroupReportsView(staleChatId, scrollToItemId)
|
||||
} else {
|
||||
LaunchedEffect(Unit) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reloadItems(chat: Chat, groupReports: State<GroupReports>) {
|
||||
val contentFilter = groupReports.value.toContentTag()
|
||||
apiLoadMessages(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId, contentFilter, ChatPagination.Initial(ChatPagination.INITIAL_COUNT))
|
||||
}
|
|
@ -27,6 +27,7 @@ import chat.simplex.common.views.chat.item.MarkdownText
|
|||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.ColumnWithScrollBar
|
||||
import chat.simplex.common.platform.chatJsonLength
|
||||
|
|
|
@ -13,6 +13,7 @@ import androidx.compose.ui.unit.sp
|
|||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
|
||||
import chat.simplex.common.platform.onRightClick
|
||||
import chat.simplex.common.views.chat.group.LocalContentTag
|
||||
|
||||
@Composable
|
||||
fun CIChatFeatureView(
|
||||
|
@ -75,9 +76,9 @@ private fun mergedFeatures(chatItem: ChatItem, chatInfo: ChatInfo): List<Feature
|
|||
val m = ChatModel
|
||||
val fs: ArrayList<FeatureInfo> = arrayListOf()
|
||||
val icons: MutableSet<PainterBox> = mutableSetOf()
|
||||
var i = getChatItemIndexOrNull(chatItem)
|
||||
val reversedChatItems = m.chatItemsForContent(LocalContentTag.current).value.asReversed()
|
||||
var i = getChatItemIndexOrNull(chatItem, reversedChatItems)
|
||||
if (i != null) {
|
||||
val reversedChatItems = m.chatItems.asReversed()
|
||||
while (i < reversedChatItems.size) {
|
||||
val f = featureInfo(reversedChatItems[i], chatInfo) ?: break
|
||||
if (!icons.contains(f.icon)) {
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
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.*
|
||||
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.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)
|
||||
|
@ -184,14 +175,26 @@ fun CIFileView(
|
|||
}
|
||||
}
|
||||
|
||||
val showOpenSaveMenu = rememberSaveable(file?.fileId) { mutableStateOf(false) }
|
||||
val ext = file?.fileSource?.filePath?.substringAfterLast(".")?.takeIf { it.isNotBlank() }
|
||||
val loadedFilePath = if (appPlatform.isAndroid && file?.fileSource != null) getLoadedFilePath(file) else null
|
||||
if (loadedFilePath != null && file?.fileSource != null) {
|
||||
val encrypted = file.fileSource.cryptoArgs != null
|
||||
SaveOrOpenFileMenu(showOpenSaveMenu, encrypted, ext, File(loadedFilePath).toURI(), file.fileSource, saveFile = { fileAction() })
|
||||
}
|
||||
Row(
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onClick = { fileAction() },
|
||||
onClick = {
|
||||
if (appPlatform.isAndroid && loadedFilePath != null) {
|
||||
showOpenSaveMenu.value = true
|
||||
} else {
|
||||
fileAction()
|
||||
}
|
||||
},
|
||||
onLongClick = { showMenu.value = true }
|
||||
)
|
||||
.padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())),
|
||||
//Modifier.clickable(enabled = file?.fileSource != null) { if (file?.fileSource != null && getLoadedFilePath(file) != null) openFile(file.fileSource) }.padding(top = 4.dp, bottom = 6.dp, start = 6.dp, end = 12.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.spacedBy(2.sp.toDp())
|
||||
) {
|
||||
|
@ -223,6 +226,47 @@ 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-large-groups-privacy-preserving-content-moderation.html#preventing-server-abuse-without-compromising-e2e-encryption"
|
||||
|
||||
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>,
|
||||
encrypted: Boolean,
|
||||
ext: String?,
|
||||
encryptedUri: URI,
|
||||
fileSource: CryptoFile,
|
||||
saveFile: () -> Unit
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun rememberSaveFileLauncher(ciFile: CIFile?): FileChooserLauncher =
|
||||
rememberFileChooserLauncher(false, ciFile) { to: URI? ->
|
||||
|
|
|
@ -238,25 +238,13 @@ fun CIImageView(
|
|||
FileProtocol.LOCAL -> {}
|
||||
}
|
||||
file.fileStatus is CIFileStatus.RcvError ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.file_error),
|
||||
file.fileStatus.rcvFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.rcvFileError)
|
||||
file.fileStatus is CIFileStatus.RcvWarning ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.temporary_file_error),
|
||||
file.fileStatus.rcvFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true)
|
||||
file.fileStatus is CIFileStatus.SndError ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.file_error),
|
||||
file.fileStatus.sndFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.sndFileError)
|
||||
file.fileStatus is CIFileStatus.SndWarning ->
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.temporary_file_error),
|
||||
file.fileStatus.sndFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
|
||||
file.fileStatus is CIFileStatus.RcvTransfer -> {} // ?
|
||||
file.fileStatus is CIFileStatus.RcvComplete -> {} // ?
|
||||
file.fileStatus is CIFileStatus.RcvCancelled -> {} // TODO
|
||||
|
|
|
@ -499,10 +499,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
|
|||
painterResource(MR.images.ic_close),
|
||||
MR.strings.icon_descr_file,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.file_error),
|
||||
file.fileStatus.sndFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.sndFileError)
|
||||
}
|
||||
)
|
||||
is CIFileStatus.SndWarning ->
|
||||
|
@ -510,10 +507,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
|
|||
painterResource(MR.images.ic_warning_filled),
|
||||
MR.strings.icon_descr_file,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.temporary_file_error),
|
||||
file.fileStatus.sndFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
|
||||
}
|
||||
)
|
||||
is CIFileStatus.RcvInvitation -> fileIcon(painterResource(MR.images.ic_arrow_downward), MR.strings.icon_descr_video_asked_to_receive)
|
||||
|
@ -532,10 +526,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
|
|||
painterResource(MR.images.ic_close),
|
||||
MR.strings.icon_descr_file,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.file_error),
|
||||
file.fileStatus.rcvFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.rcvFileError)
|
||||
}
|
||||
)
|
||||
is CIFileStatus.RcvWarning ->
|
||||
|
@ -543,10 +534,7 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
|
|||
painterResource(MR.images.ic_warning_filled),
|
||||
MR.strings.icon_descr_file,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.temporary_file_error),
|
||||
file.fileStatus.rcvFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true)
|
||||
}
|
||||
)
|
||||
is CIFileStatus.Invalid -> fileIcon(painterResource(MR.images.ic_question_mark), MR.strings.icon_descr_file)
|
||||
|
|
|
@ -398,10 +398,7 @@ private fun VoiceMsgIndicator(
|
|||
sizeMultiplier,
|
||||
longClick,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.file_error),
|
||||
file.fileStatus.sndFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.sndFileError)
|
||||
}
|
||||
)
|
||||
file != null && file.fileStatus is CIFileStatus.SndWarning ->
|
||||
|
@ -411,10 +408,7 @@ private fun VoiceMsgIndicator(
|
|||
sizeMultiplier,
|
||||
longClick,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.temporary_file_error),
|
||||
file.fileStatus.sndFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.sndFileError, temporary = true)
|
||||
}
|
||||
)
|
||||
file?.fileStatus is CIFileStatus.RcvInvitation ->
|
||||
|
@ -430,10 +424,7 @@ private fun VoiceMsgIndicator(
|
|||
sizeMultiplier,
|
||||
longClick,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.file_error),
|
||||
file.fileStatus.rcvFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.rcvFileError)
|
||||
}
|
||||
)
|
||||
file != null && file.fileStatus is CIFileStatus.RcvWarning ->
|
||||
|
@ -443,10 +434,7 @@ private fun VoiceMsgIndicator(
|
|||
sizeMultiplier,
|
||||
longClick,
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
generalGetString(MR.strings.temporary_file_error),
|
||||
file.fileStatus.rcvFileError.errorInfo
|
||||
)
|
||||
showFileErrorAlert(file.fileStatus.rcvFileError, temporary = true)
|
||||
}
|
||||
)
|
||||
file != null && file.loaded && progress != null && duration != null ->
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.HoverInteraction
|
||||
|
@ -20,6 +21,7 @@ import androidx.compose.ui.text.*
|
|||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.model.*
|
||||
|
@ -28,6 +30,7 @@ import chat.simplex.common.model.ChatModel.currentUser
|
|||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
import chat.simplex.common.views.chat.group.LocalContentTag
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.datetime.Clock
|
||||
|
@ -255,7 +258,7 @@ fun ChatItemView(
|
|||
|
||||
@Composable
|
||||
fun MsgReactionsMenu() {
|
||||
val rs = MsgReaction.values.mapNotNull { r ->
|
||||
val rs = MsgReaction.old.mapNotNull { r ->
|
||||
if (null == cItem.reactions.find { it.userReacted && it.reaction.text == r.text }) {
|
||||
r
|
||||
} else {
|
||||
|
@ -295,7 +298,17 @@ fun ChatItemView(
|
|||
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
|
||||
when {
|
||||
// cItem.id check is a special case for live message chat item which has negative ID while not sent yet
|
||||
cItem.content.msgContent != null && cItem.id >= 0 -> {
|
||||
cItem.isReport && cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (cItem.chatDir !is CIDirection.GroupSnd && cInfo.groupInfo.membership.memberRole >= GroupMemberRole.Moderator) {
|
||||
ArchiveReportItemAction(cItem, showMenu, deleteMessage)
|
||||
}
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages, buttonText = stringResource(MR.strings.delete_report))
|
||||
Divider()
|
||||
SelectItemAction(showMenu, selectChatItem)
|
||||
}
|
||||
}
|
||||
cItem.content.msgContent != null && cItem.id >= 0 && !cItem.isReport -> {
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
|
||||
MsgReactionsMenu()
|
||||
|
@ -383,9 +396,13 @@ fun ChatItemView(
|
|||
if (!(live && cItem.meta.isLive) && !preview) {
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
|
||||
}
|
||||
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||
if (groupInfo != null && cItem.chatDir !is CIDirection.GroupSnd) {
|
||||
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage)
|
||||
if (cItem.chatDir !is CIDirection.GroupSnd) {
|
||||
val groupInfo = cItem.memberToModerate(cInfo)?.first
|
||||
if (groupInfo != null) {
|
||||
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(cInfo.featureEnabled(ChatFeature.FullDelete), 1), showMenu, deleteMessage)
|
||||
} // else if (cItem.meta.itemDeleted == null && cInfo is ChatInfo.Group && cInfo.groupInfo.membership.memberRole == GroupMemberRole.Member && !live) {
|
||||
// ReportItemAction(cItem, composeState, showMenu)
|
||||
// }
|
||||
}
|
||||
if (cItem.canBeDeletedForSelf) {
|
||||
Divider()
|
||||
|
@ -465,7 +482,7 @@ fun ChatItemView(
|
|||
fun ContentItem() {
|
||||
val mc = cItem.content.msgContent
|
||||
if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) {
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
MarkedDeletedItemDropdownMenu()
|
||||
} else {
|
||||
if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
|
||||
|
@ -500,8 +517,8 @@ fun ChatItemView(
|
|||
DeleteItemMenu()
|
||||
}
|
||||
|
||||
fun mergedGroupEventText(chatItem: ChatItem): String? {
|
||||
val (count, ns) = chatModel.getConnectedMemberNames(chatItem)
|
||||
fun mergedGroupEventText(chatItem: ChatItem, reversedChatItems: List<ChatItem>): String? {
|
||||
val (count, ns) = chatModel.getConnectedMemberNames(chatItem, reversedChatItems)
|
||||
val members = when {
|
||||
ns.size == 1 -> String.format(generalGetString(MR.strings.rcv_group_event_1_member_connected), ns[0])
|
||||
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
|
||||
|
@ -520,9 +537,9 @@ fun ChatItemView(
|
|||
}
|
||||
}
|
||||
|
||||
fun eventItemViewText(): AnnotatedString {
|
||||
fun eventItemViewText(reversedChatItems: List<ChatItem>): AnnotatedString {
|
||||
val memberDisplayName = cItem.memberDisplayName
|
||||
val t = mergedGroupEventText(cItem)
|
||||
val t = mergedGroupEventText(cItem, reversedChatItems)
|
||||
return if (!revealed.value && t != null) {
|
||||
chatEventText(t, cItem.timestampText)
|
||||
} else if (memberDisplayName != null) {
|
||||
|
@ -536,12 +553,13 @@ fun ChatItemView(
|
|||
}
|
||||
|
||||
@Composable fun EventItemView() {
|
||||
CIEventView(eventItemViewText())
|
||||
val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed()
|
||||
CIEventView(eventItemViewText(reversedChatItems))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DeletedItem() {
|
||||
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
MarkedDeletedItemView(cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
|
||||
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
|
||||
|
@ -728,21 +746,23 @@ fun DeleteItemAction(
|
|||
questionText: String,
|
||||
deleteMessage: (Long, CIDeleteMode) -> Unit,
|
||||
deleteMessages: (List<Long>) -> Unit,
|
||||
buttonText: String = stringResource(MR.strings.delete_verb),
|
||||
) {
|
||||
val contentTag = LocalContentTag.current
|
||||
ItemAction(
|
||||
stringResource(MR.strings.delete_verb),
|
||||
buttonText,
|
||||
painterResource(MR.images.ic_delete),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
if (!revealed.value) {
|
||||
val currIndex = chatModel.getChatItemIndexOrNull(cItem)
|
||||
val reversedChatItems = chatModel.chatItemsForContent(contentTag).value.asReversed()
|
||||
val currIndex = chatModel.getChatItemIndexOrNull(cItem, reversedChatItems)
|
||||
val ciCategory = cItem.mergeCategory
|
||||
if (currIndex != null && ciCategory != null) {
|
||||
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
|
||||
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory, reversedChatItems)
|
||||
val range = chatViewItemsRange(currIndex, prevHidden)
|
||||
if (range != null) {
|
||||
val itemIds: ArrayList<Long> = arrayListOf()
|
||||
val reversedChatItems = chatModel.chatItems.asReversed()
|
||||
for (i in range) {
|
||||
itemIds.add(reversedChatItems[i].id)
|
||||
}
|
||||
|
@ -847,6 +867,73 @@ private fun ShrinkItemAction(revealed: State<Boolean>, showMenu: MutableState<Bo
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ReportItemAction(
|
||||
cItem: ChatItem,
|
||||
composeState: MutableState<ComposeState>,
|
||||
showMenu: MutableState<Boolean>,
|
||||
) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.report_verb),
|
||||
painterResource(MR.images.ic_flag),
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertDialogButtons(
|
||||
title = generalGetString(MR.strings.report_reason_alert_title),
|
||||
buttons = {
|
||||
ReportReason.supportedReasons.forEach { reason ->
|
||||
SectionItemView({
|
||||
if (composeState.value.editing) {
|
||||
composeState.value = ComposeState(
|
||||
contextItem = ComposeContextItem.ReportedItem(cItem, reason),
|
||||
useLinkPreviews = false,
|
||||
preview = ComposePreview.NoPreview,
|
||||
)
|
||||
} else {
|
||||
composeState.value = composeState.value.copy(
|
||||
contextItem = ComposeContextItem.ReportedItem(cItem, reason),
|
||||
useLinkPreviews = false,
|
||||
preview = ComposePreview.NoPreview,
|
||||
)
|
||||
}
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(reason.text, Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArchiveReportItemAction(cItem: ChatItem, showMenu: MutableState<Boolean>, deleteMessage: (Long, CIDeleteMode) -> Unit) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.archive_report),
|
||||
painterResource(MR.images.ic_inventory_2),
|
||||
onClick = {
|
||||
AlertManager.shared.showAlertDialog(
|
||||
title = generalGetString(MR.strings.report_archive_alert_title),
|
||||
text = generalGetString(MR.strings.report_archive_alert_desc),
|
||||
onConfirm = {
|
||||
deleteMessage(cItem.id, CIDeleteMode.cidmInternalMark)
|
||||
},
|
||||
destructive = true,
|
||||
confirmText = generalGetString(MR.strings.archive_verb),
|
||||
)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, onClick: () -> Unit) {
|
||||
val finalColor = if (color == Color.Unspecified) {
|
||||
|
@ -867,6 +954,32 @@ fun ItemAction(text: String, icon: Painter, color: Color = Color.Unspecified, on
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(text: String, icon: ImageBitmap, textColor: Color = Color.Unspecified, iconColor: Color = Color.Unspecified, onClick: () -> Unit) {
|
||||
val finalColor = if (textColor == Color.Unspecified) {
|
||||
MenuTextColor
|
||||
} else textColor
|
||||
DropdownMenuItem(onClick, contentPadding = PaddingValues(horizontal = DEFAULT_PADDING * 1.5f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1F)
|
||||
.padding(end = 15.dp),
|
||||
color = finalColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (iconColor == Color.Unspecified) {
|
||||
Image(icon, text, Modifier.size(22.dp))
|
||||
} else {
|
||||
Icon(icon, text, Modifier.size(22.dp), tint = iconColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ItemAction(
|
||||
text: String,
|
||||
|
@ -1107,7 +1220,7 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
|
|||
deleteMessage(chatItem.id, CIDeleteMode.cidmInternal)
|
||||
AlertManager.shared.hideAlert()
|
||||
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
|
||||
if (chatItem.meta.deletable && !chatItem.localNote) {
|
||||
if (chatItem.meta.deletable && !chatItem.localNote && !chatItem.isReport) {
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
TextButton(onClick = {
|
||||
deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast)
|
||||
|
|
|
@ -88,7 +88,7 @@ fun FramedItemView(
|
|||
}
|
||||
|
||||
@Composable
|
||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false) {
|
||||
fun FramedItemHeader(caption: String, italic: Boolean, icon: Painter? = null, pad: Boolean = false, iconColor: Color? = null) {
|
||||
val sentColor = MaterialTheme.appColors.sentQuote
|
||||
val receivedColor = MaterialTheme.appColors.receivedQuote
|
||||
Row(
|
||||
|
@ -104,7 +104,7 @@ fun FramedItemView(
|
|||
icon,
|
||||
caption,
|
||||
Modifier.size(18.dp),
|
||||
tint = if (isInDarkTheme()) FileDark else FileLight
|
||||
tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight
|
||||
)
|
||||
}
|
||||
Text(
|
||||
|
@ -128,17 +128,6 @@ fun FramedItemView(
|
|||
Modifier
|
||||
.background(if (sent) sentColor else receivedColor)
|
||||
.fillMaxWidth()
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = {
|
||||
if (qi.itemId != null) {
|
||||
scrollToItem(qi.itemId)
|
||||
} else {
|
||||
scrollToQuotedItemFromItem(ci.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
when (qi.content) {
|
||||
is MsgContent.MCImage -> {
|
||||
|
@ -216,28 +205,66 @@ fun FramedItemView(
|
|||
.padding(start = if (tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp)
|
||||
) {
|
||||
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
|
||||
if (ci.meta.itemDeleted != null) {
|
||||
when (ci.meta.itemDeleted) {
|
||||
is CIDeleted.Moderated -> {
|
||||
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
|
||||
@Composable
|
||||
fun Header() {
|
||||
if (ci.isReport) {
|
||||
if (ci.meta.itemDeleted == null) {
|
||||
FramedItemHeader(
|
||||
stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators),
|
||||
true,
|
||||
painterResource(MR.images.ic_flag),
|
||||
iconColor = Color.Red
|
||||
)
|
||||
} else {
|
||||
val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) {
|
||||
stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName)
|
||||
} else {
|
||||
stringResource(MR.strings.report_item_archived)
|
||||
}
|
||||
FramedItemHeader(text, true, painterResource(MR.images.ic_flag))
|
||||
}
|
||||
is CIDeleted.Blocked -> {
|
||||
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
|
||||
}
|
||||
is CIDeleted.BlockedByAdmin -> {
|
||||
FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand))
|
||||
}
|
||||
is CIDeleted.Deleted -> {
|
||||
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
|
||||
} else if (ci.meta.itemDeleted != null) {
|
||||
when (ci.meta.itemDeleted) {
|
||||
is CIDeleted.Moderated -> {
|
||||
FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag))
|
||||
}
|
||||
is CIDeleted.Blocked -> {
|
||||
FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand))
|
||||
}
|
||||
is CIDeleted.BlockedByAdmin -> {
|
||||
FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand))
|
||||
}
|
||||
is CIDeleted.Deleted -> {
|
||||
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
|
||||
}
|
||||
}
|
||||
} else if (ci.meta.isLive) {
|
||||
FramedItemHeader(stringResource(MR.strings.live), false)
|
||||
}
|
||||
} else if (ci.meta.isLive) {
|
||||
FramedItemHeader(stringResource(MR.strings.live), false)
|
||||
}
|
||||
if (ci.quotedItem != null) {
|
||||
ciQuoteView(ci.quotedItem)
|
||||
} else if (ci.meta.itemForwarded != null) {
|
||||
FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
|
||||
Column(
|
||||
Modifier
|
||||
.combinedClickable(
|
||||
onLongClick = { showMenu.value = true },
|
||||
onClick = {
|
||||
if (ci.quotedItem.itemId != null) {
|
||||
scrollToItem(ci.quotedItem.itemId)
|
||||
} else {
|
||||
scrollToQuotedItemFromItem(ci.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
.onRightClick { showMenu.value = true }
|
||||
) {
|
||||
Header()
|
||||
ciQuoteView(ci.quotedItem)
|
||||
}
|
||||
} else {
|
||||
Header()
|
||||
if (ci.meta.itemForwarded != null) {
|
||||
FramedItemHeader(ci.meta.itemForwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true)
|
||||
}
|
||||
}
|
||||
if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) {
|
||||
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
|
||||
|
@ -288,6 +315,14 @@ fun FramedItemView(
|
|||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
is MsgContent.MCReport -> {
|
||||
val prefix = buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
|
||||
append(if (mc.text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
|
||||
}
|
||||
}
|
||||
CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix)
|
||||
}
|
||||
else -> CIMarkdownText(ci, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
}
|
||||
|
@ -315,13 +350,14 @@ fun CIMarkdownText(
|
|||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean,
|
||||
showTimestamp: Boolean,
|
||||
prefix: AnnotatedString? = null
|
||||
) {
|
||||
Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) {
|
||||
val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text
|
||||
MarkdownText(
|
||||
text, if (text.isEmpty()) emptyList() else ci.formattedText, toggleSecrets = true,
|
||||
meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode,
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp
|
||||
uriHandler = uriHandler, senderBold = true, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,15 +12,17 @@ import androidx.compose.runtime.*
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.chatModel
|
||||
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.chat.group.LocalContentTag
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@Composable
|
||||
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
fun MarkedDeletedItemView(ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State<Boolean>, showViaProxy: Boolean, showTimestamp: Boolean) {
|
||||
val sentColor = MaterialTheme.appColors.sentMessage
|
||||
val receivedColor = MaterialTheme.appColors.receivedMessage
|
||||
Surface(
|
||||
|
@ -33,7 +35,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.weight(1f, false)) {
|
||||
MergedMarkedDeletedText(ci, revealed)
|
||||
MergedMarkedDeletedText(ci, chatInfo, revealed)
|
||||
}
|
||||
CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
|
||||
}
|
||||
|
@ -41,11 +43,11 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: State<
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>) {
|
||||
var i = getChatItemIndexOrNull(chatItem)
|
||||
private fun MergedMarkedDeletedText(chatItem: ChatItem, chatInfo: ChatInfo, revealed: State<Boolean>) {
|
||||
val reversedChatItems = chatModel.chatItemsForContent(LocalContentTag.current).value.asReversed()
|
||||
var i = getChatItemIndexOrNull(chatItem, reversedChatItems)
|
||||
val ciCategory = chatItem.mergeCategory
|
||||
val text = if (!revealed.value && ciCategory != null && i != null) {
|
||||
val reversedChatItems = ChatModel.chatItems.asReversed()
|
||||
var moderated = 0
|
||||
var blocked = 0
|
||||
var blockedByAdmin = 0
|
||||
|
@ -67,7 +69,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
|
|||
}
|
||||
val total = moderated + blocked + blockedByAdmin + deleted
|
||||
if (total <= 1)
|
||||
markedDeletedText(chatItem.meta)
|
||||
markedDeletedText(chatItem, chatInfo)
|
||||
else if (total == moderated)
|
||||
stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", "))
|
||||
else if (total == blockedByAdmin)
|
||||
|
@ -77,7 +79,7 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
|
|||
else
|
||||
stringResource(MR.strings.marked_deleted_items_description).format(total)
|
||||
} else {
|
||||
markedDeletedText(chatItem.meta)
|
||||
markedDeletedText(chatItem, chatInfo)
|
||||
}
|
||||
|
||||
Text(
|
||||
|
@ -91,10 +93,17 @@ private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: State<Boolean>
|
|||
)
|
||||
}
|
||||
|
||||
fun markedDeletedText(meta: CIMeta): String =
|
||||
when (meta.itemDeleted) {
|
||||
fun markedDeletedText(cItem: ChatItem, chatInfo: ChatInfo): String =
|
||||
if (cItem.meta.itemDeleted != null && cItem.isReport) {
|
||||
if (cItem.meta.itemDeleted is CIDeleted.Moderated && cItem.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) {
|
||||
generalGetString(MR.strings.report_item_archived_by).format(cItem.meta.itemDeleted.byGroupMember.displayName)
|
||||
} else {
|
||||
generalGetString(MR.strings.report_item_archived)
|
||||
}
|
||||
}
|
||||
else when (cItem.meta.itemDeleted) {
|
||||
is CIDeleted.Moderated ->
|
||||
String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName)
|
||||
String.format(generalGetString(MR.strings.moderated_item_description), cItem.meta.itemDeleted.byGroupMember.displayName)
|
||||
is CIDeleted.Blocked ->
|
||||
generalGetString(MR.strings.blocked_item_description)
|
||||
is CIDeleted.BlockedByAdmin ->
|
||||
|
|
|
@ -71,7 +71,8 @@ fun MarkdownText (
|
|||
inlineContent: Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>>? = null,
|
||||
onLinkLongClick: (link: String) -> Unit = {},
|
||||
showViaProxy: Boolean = false,
|
||||
showTimestamp: Boolean = true
|
||||
showTimestamp: Boolean = true,
|
||||
prefix: AnnotatedString? = null
|
||||
) {
|
||||
val textLayoutDirection = remember (text) {
|
||||
if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr
|
||||
|
@ -123,6 +124,7 @@ fun MarkdownText (
|
|||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
if (text is String) append(text)
|
||||
else if (text is AnnotatedString) append(text)
|
||||
if (meta?.isLive == true) {
|
||||
|
@ -136,6 +138,7 @@ fun MarkdownText (
|
|||
val annotatedText = buildAnnotatedString {
|
||||
inlineContent?.first?.invoke(this)
|
||||
appendSender(this, sender, senderBold)
|
||||
if (prefix != null) append(prefix)
|
||||
for ((i, ft) in formattedText.withIndex()) {
|
||||
if (ft.format == null) append(ft.text)
|
||||
else if (toggleSecrets && ft.format is Format.Secret) {
|
||||
|
|
|
@ -4,8 +4,6 @@ import SectionItemView
|
|||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
|
@ -13,7 +11,8 @@ 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.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.*
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
@ -21,11 +20,11 @@ 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.chat.*
|
||||
import chat.simplex.common.views.chat.group.deleteGroupDialog
|
||||
import chat.simplex.common.views.chat.group.leaveGroupDialog
|
||||
import chat.simplex.common.views.chat.group.*
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.contacts.onRequestAccepted
|
||||
import chat.simplex.common.views.helpers.*
|
||||
|
@ -33,7 +32,6 @@ import chat.simplex.common.views.newchat.*
|
|||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.math.min
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
|
||||
|
@ -66,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)
|
||||
|
@ -84,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)
|
||||
|
@ -102,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) } },
|
||||
|
@ -204,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
|
||||
|
@ -236,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()
|
||||
}
|
||||
|
||||
|
@ -246,12 +251,13 @@ 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)
|
||||
}
|
||||
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
|
||||
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||
TagListAction(chat, showMenu)
|
||||
ClearChatAction(chat, showMenu)
|
||||
}
|
||||
DeleteContactAction(chat, chatModel, showMenu)
|
||||
|
@ -285,12 +291,13 @@ fun GroupMenuItems(
|
|||
}
|
||||
else -> {
|
||||
if (showMarkRead) {
|
||||
MarkReadChatAction(chat, chatModel, showMenu)
|
||||
MarkReadChatAction(chat, showMenu)
|
||||
} else {
|
||||
MarkUnreadChatAction(chat, chatModel, showMenu)
|
||||
}
|
||||
ToggleFavoritesChatAction(chat, chatModel, chat.chatInfo.chatSettings?.favorite == true, showMenu)
|
||||
ToggleNotificationsChatAction(chat, chatModel, chat.chatInfo.ntfsEnabled, showMenu)
|
||||
TagListAction(chat, showMenu)
|
||||
ClearChatAction(chat, showMenu)
|
||||
if (groupInfo.membership.memberCurrent) {
|
||||
LeaveGroupAction(chat.remoteHostId, groupInfo, chatModel, showMenu)
|
||||
|
@ -305,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)
|
||||
}
|
||||
|
@ -313,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
|
||||
}
|
||||
|
@ -337,6 +344,28 @@ fun MarkUnreadChatAction(chat: Chat, chatModel: ChatModel, showMenu: MutableStat
|
|||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TagListAction(
|
||||
chat: Chat,
|
||||
showMenu: MutableState<Boolean>
|
||||
) {
|
||||
val userTags = remember { chatModel.userTags }
|
||||
ItemAction(
|
||||
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, reorderMode = false)
|
||||
}
|
||||
}
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToggleFavoritesChatAction(chat: Chat, chatModel: ChatModel, favorite: Boolean, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
|
@ -533,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,
|
||||
|
@ -557,6 +589,7 @@ fun markChatRead(c: Chat, chatModel: ChatModel) {
|
|||
if (success) {
|
||||
withChats {
|
||||
replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = false)))
|
||||
markChatTagRead(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -568,6 +601,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
|
|||
if (chat.chatStats.unreadChat) return
|
||||
|
||||
withApi {
|
||||
val wasUnread = chat.unreadTag
|
||||
val success = chatModel.controller.apiChatUnread(
|
||||
chat.remoteHostId,
|
||||
chat.chatInfo.chatType,
|
||||
|
@ -577,6 +611,7 @@ fun markChatUnread(chat: Chat, chatModel: ChatModel) {
|
|||
if (success) {
|
||||
withChats {
|
||||
replaceChat(chat.remoteHostId, chat.id, chat.copy(chatStats = chat.chatStats.copy(unreadChat = true)))
|
||||
updateChatTagReadNoContentTag(chat, wasUnread)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -826,12 +861,22 @@ fun updateChatSettings(remoteHostId: Long?, chatInfo: ChatInfo, chatSettings: Ch
|
|||
else -> false
|
||||
}
|
||||
if (res && newChatInfo != null) {
|
||||
val chat = chatModel.getChat(chatInfo.id)
|
||||
val wasUnread = chat?.unreadTag ?: false
|
||||
val wasFavorite = chatInfo.chatSettings?.favorite ?: false
|
||||
chatModel.updateChatFavorite(favorite = chatSettings.favorite, wasFavorite)
|
||||
withChats {
|
||||
updateChatInfo(remoteHostId, newChatInfo)
|
||||
}
|
||||
if (chatSettings.enableNtfs != MsgFilter.All) {
|
||||
ntfManager.cancelNotificationsForChat(chatInfo.id)
|
||||
}
|
||||
val updatedChat = chatModel.getChat(chatInfo.id)
|
||||
if (updatedChat != null) {
|
||||
withChats {
|
||||
updateChatTagReadNoContentTag(updatedChat, wasUnread)
|
||||
}
|
||||
}
|
||||
val current = currentState?.value
|
||||
if (current != null) {
|
||||
currentState.value = !current
|
||||
|
@ -883,7 +928,8 @@ fun PreviewChatListNavLinkDirect() {
|
|||
disabled = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
inProgress = false,
|
||||
progressByTimeout = false
|
||||
progressByTimeout = false,
|
||||
{}
|
||||
)
|
||||
},
|
||||
click = {},
|
||||
|
@ -928,7 +974,8 @@ fun PreviewChatListNavLinkGroup() {
|
|||
disabled = false,
|
||||
linkMode = SimplexLinkMode.DESCRIPTION,
|
||||
inProgress = false,
|
||||
progressByTimeout = false
|
||||
progressByTimeout = false,
|
||||
{}
|
||||
)
|
||||
},
|
||||
click = {},
|
||||
|
|
|
@ -16,11 +16,13 @@ import androidx.compose.ui.focus.*
|
|||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.platform.*
|
||||
import androidx.compose.ui.text.TextRange
|
||||
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.input.TextFieldValue
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.AppLock
|
||||
import chat.simplex.common.model.*
|
||||
|
@ -31,22 +33,30 @@ import chat.simplex.common.ui.theme.*
|
|||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.Call
|
||||
import chat.simplex.common.views.chat.item.CIFileViewScope
|
||||
import chat.simplex.common.views.chat.item.*
|
||||
import chat.simplex.common.views.chat.topPaddingToContent
|
||||
import chat.simplex.common.views.mkValidName
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.common.views.onboarding.*
|
||||
import chat.simplex.common.views.showInvalidNameAlert
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.ConditionsLinkButton
|
||||
import chat.simplex.common.views.usersettings.networkAndServers.UsageConditionsView
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.ImageResource
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
enum class PresetTagKind { GROUP_REPORTS, FAVORITES, CONTACTS, GROUPS, BUSINESS, NOTES }
|
||||
|
||||
sealed class ActiveFilter {
|
||||
data class PresetTag(val tag: PresetTagKind) : ActiveFilter()
|
||||
data class UserTag(val tag: ChatTag) : ActiveFilter()
|
||||
data object Unread: ActiveFilter()
|
||||
}
|
||||
|
||||
private fun showNewChatSheet(oneHandUI: State<Boolean>) {
|
||||
ModalManager.start.closeModals()
|
||||
ModalManager.end.closeModals()
|
||||
|
@ -142,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()
|
||||
|
@ -187,6 +197,12 @@ fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<Animate
|
|||
}
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
val wasAllowedToSetupNotifications = rememberSaveable { mutableStateOf(false) }
|
||||
val canEnableNotifications = remember { derivedStateOf { chatModel.chatRunning.value == true } }
|
||||
if (wasAllowedToSetupNotifications.value || canEnableNotifications.value) {
|
||||
SetNotificationsModeAdditions()
|
||||
LaunchedEffect(Unit) { wasAllowedToSetupNotifications.value = true }
|
||||
}
|
||||
tryOrShowError("UserPicker", error = {}) {
|
||||
UserPicker(
|
||||
chatModel = chatModel,
|
||||
|
@ -557,17 +573,24 @@ private fun BoxScope.unreadBadge(text: String? = "") {
|
|||
|
||||
@Composable
|
||||
private fun ToggleFilterEnabledButton() {
|
||||
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
|
||||
IconButton(onClick = { pref.set(!pref.get()) }) {
|
||||
val showUnread = remember { chatModel.activeChatTagFilter }.value == ActiveFilter.Unread
|
||||
|
||||
IconButton(onClick = {
|
||||
if (showUnread) {
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
} else {
|
||||
chatModel.activeChatTagFilter.value = ActiveFilter.Unread
|
||||
}
|
||||
}) {
|
||||
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
|
||||
Icon(
|
||||
painterResource(MR.images.ic_filter_list),
|
||||
null,
|
||||
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
|
||||
tint = if (showUnread) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.padding(3.dp)
|
||||
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
|
||||
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
|
||||
.background(color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
|
||||
.border(width = 1.dp, color = if (showUnread) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
|
||||
.padding(3.dp)
|
||||
.size(sp16)
|
||||
)
|
||||
|
@ -731,6 +754,7 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
|||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val oneHandUICardShown = remember { appPrefs.oneHandUICardShown.state }
|
||||
val addressCreationCardShown = remember { appPrefs.addressCreationCardShown.state }
|
||||
val activeFilter = remember { chatModel.activeChatTagFilter }
|
||||
|
||||
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
|
||||
val currentIndex = listState.firstVisibleItemIndex
|
||||
|
@ -753,14 +777,13 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
|||
DisposableEffect(Unit) {
|
||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
}
|
||||
val showUnreadAndFavorites = remember { ChatController.appPrefs.showUnreadAndFavorites.state }.value
|
||||
val allChats = remember { chatModel.chats }
|
||||
// In some not always reproducible situations this code produce IndexOutOfBoundsException on Compose's side
|
||||
// which is related to [derivedStateOf]. Using safe alternative instead
|
||||
// val chats by remember(search, showUnreadAndFavorites) { derivedStateOf { filteredChats(showUnreadAndFavorites, search, allChats.toList()) } }
|
||||
val searchShowingSimplexLink = remember { mutableStateOf(false) }
|
||||
val searchChatFilteredBySimplexLink = remember { mutableStateOf<String?>(null) }
|
||||
val chats = filteredChats(showUnreadAndFavorites, searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList())
|
||||
val chats = filteredChats(searchShowingSimplexLink, searchChatFilteredBySimplexLink, searchText.value.text, allChats.value.toList(), activeFilter.value)
|
||||
val topPaddingToContent = topPaddingToContent(false)
|
||||
val blankSpaceSize = if (oneHandUI.value) WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + AppBarHeight * fontSizeSqrtMultiplier else topPaddingToContent
|
||||
LazyColumnWithScrollBar(
|
||||
|
@ -791,11 +814,15 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
|||
) {
|
||||
if (oneHandUI.value) {
|
||||
Column(Modifier.consumeWindowInsets(WindowInsets.navigationBars).consumeWindowInsets(PaddingValues(bottom = AppBarHeight))) {
|
||||
Divider()
|
||||
TagsView(searchText)
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
|
||||
}
|
||||
} else {
|
||||
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
|
||||
TagsView(searchText)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -815,8 +842,8 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
|||
}
|
||||
}
|
||||
if (chats.isEmpty() && chatModel.chats.value.isNotEmpty()) {
|
||||
Box(Modifier.fillMaxSize().imePadding(), contentAlignment = Alignment.Center) {
|
||||
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
|
||||
Box(Modifier.fillMaxSize().imePadding().padding(horizontal = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
|
||||
NoChatsView(searchText = searchText)
|
||||
}
|
||||
}
|
||||
if (oneHandUI.value) {
|
||||
|
@ -839,6 +866,41 @@ private fun BoxScope.ChatList(searchText: MutableState<TextFieldValue>, listStat
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(activeFilter.value) {
|
||||
searchText.value = TextFieldValue("")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoChatsView(searchText: MutableState<TextFieldValue>) {
|
||||
val activeFilter = remember { chatModel.activeChatTagFilter }.value
|
||||
|
||||
if (searchText.value.text.isBlank()) {
|
||||
when (activeFilter) {
|
||||
is ActiveFilter.PresetTag -> Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center) // this should not happen
|
||||
is ActiveFilter.UserTag -> Text(String.format(generalGetString(MR.strings.no_chats_in_list), activeFilter.tag.chatTagText), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
|
||||
is ActiveFilter.Unread -> {
|
||||
Row(
|
||||
Modifier.clip(shape = CircleShape).clickable { chatModel.activeChatTagFilter.value = null }.padding(DEFAULT_PADDING_HALF),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_filter_list),
|
||||
null,
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
Text(generalGetString(MR.strings.no_unread_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
null -> {
|
||||
Text(generalGetString(MR.strings.no_chats), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text(generalGetString(MR.strings.no_chats_found), color = MaterialTheme.colors.secondary, textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
@ -860,48 +922,346 @@ private fun ChatListFeatureCards() {
|
|||
}
|
||||
}
|
||||
|
||||
fun filteredChats(
|
||||
showUnreadAndFavorites: Boolean,
|
||||
searchShowingSimplexLink: State<Boolean>,
|
||||
searchChatFilteredBySimplexLink: State<String?>,
|
||||
searchText: String,
|
||||
chats: List<Chat>
|
||||
): List<Chat> {
|
||||
val linkChatId = searchChatFilteredBySimplexLink.value
|
||||
return if (linkChatId != null) {
|
||||
chats.filter { it.id == linkChatId }
|
||||
} else {
|
||||
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
|
||||
if (s.isEmpty() && !showUnreadAndFavorites)
|
||||
chats.filter { chat -> !chat.chatInfo.chatDeleted && chatContactType(chat) != ContactType.CARD }
|
||||
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)
|
||||
} else {
|
||||
cInfo.anyNameContains(s)
|
||||
})
|
||||
is ChatInfo.Group -> if (s.isEmpty()) {
|
||||
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
|
||||
private val TAG_MIN_HEIGHT = 35.dp
|
||||
|
||||
@Composable
|
||||
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()
|
||||
|
||||
val rowSizeModifier = Modifier.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
|
||||
|
||||
TagsRow {
|
||||
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(searchText)
|
||||
alwaysShownPresetTags.forEach { tag ->
|
||||
ExpandedTagFilterView(tag.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userTags.value.forEach { tag ->
|
||||
val current = when (val af = activeFilter.value) {
|
||||
is ActiveFilter.UserTag -> af.tag == tag
|
||||
else -> false
|
||||
}
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
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 {
|
||||
cInfo.anyNameContains(s)
|
||||
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
|
||||
)
|
||||
}
|
||||
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
|
||||
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 = 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
|
||||
.clickable {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
TagListEditor(rhId = rhId, close = close)
|
||||
}
|
||||
}
|
||||
|
||||
if (userTags.value.isEmpty()) {
|
||||
Row(rowSizeModifier.clip(shape = CircleShape).then(plusClickModifier).padding(start = 2.dp, top = 4.dp, end = 6.dp, bottom = 4.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.secondary)
|
||||
Spacer(Modifier.width(2.dp))
|
||||
Text(stringResource(MR.strings.chat_list_add_list), color = MaterialTheme.colors.secondary, fontSize = 15.sp)
|
||||
}
|
||||
} else {
|
||||
Box(rowSizeModifier, contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_add), stringResource(MR.strings.chat_list_add_list), Modifier.clip(shape = CircleShape).then(plusClickModifier).padding(2.dp), tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun TagsRow(content: @Composable() (() -> Unit))
|
||||
|
||||
@Composable
|
||||
private fun ExpandedTagFilterView(tag: PresetTagKind) {
|
||||
val activeFilter = remember { chatModel.activeChatTagFilter }
|
||||
val active = when (val af = activeFilter.value) {
|
||||
is ActiveFilter.PresetTag -> af.tag == tag
|
||||
else -> false
|
||||
}
|
||||
val (icon, text) = presetTagLabel(tag, active)
|
||||
val color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.sizeIn(minHeight = TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
|
||||
.clip(shape = CircleShape)
|
||||
.clickable {
|
||||
if (activeFilter.value == ActiveFilter.PresetTag(tag)) {
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
} else {
|
||||
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(tag)
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 5.dp, vertical = 4.dp)
|
||||
,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painterResource(icon),
|
||||
stringResource(text),
|
||||
Modifier.size(18.sp.toDp()),
|
||||
tint = color
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Box {
|
||||
Text(
|
||||
stringResource(text),
|
||||
color = if (active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
|
||||
fontWeight = if (active) FontWeight.Medium else FontWeight.Normal,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
Text(
|
||||
stringResource(text),
|
||||
color = Color.Transparent,
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 15.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
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 -> if (presetCanBeCollapsed(af.tag)) af.tag else null
|
||||
else -> null
|
||||
}
|
||||
|
||||
Box(Modifier
|
||||
.clip(shape = CircleShape)
|
||||
.size(TAG_MIN_HEIGHT * fontSizeSqrtMultiplier)
|
||||
.clickable { showMenu.value = true },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (selectedPresetTag != null) {
|
||||
val (icon, text) = presetTagLabel(selectedPresetTag, true)
|
||||
Icon(
|
||||
painterResource(icon),
|
||||
stringResource(text),
|
||||
Modifier.size(18.sp.toDp()),
|
||||
tint = MaterialTheme.colors.primary
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_menu),
|
||||
stringResource(MR.strings.chat_list_all),
|
||||
tint = MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
|
||||
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 = {
|
||||
onCloseMenuAction.value = {
|
||||
searchText.value = TextFieldValue()
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
onCloseMenuAction.value = {}
|
||||
}
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
PresetTagKind.entries.forEach { tag ->
|
||||
if ((presetTags[tag] ?: 0) > 0 && presetCanBeCollapsed(tag)) {
|
||||
ItemPresetFilterAction(tag, tag == selectedPresetTag, showMenu, onCloseMenuAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filtered(chat: Chat): Boolean =
|
||||
(chat.chatInfo.chatSettings?.favorite ?: false) ||
|
||||
chat.chatStats.unreadChat ||
|
||||
(chat.chatInfo.ntfsEnabled && chat.chatStats.unreadCount > 0)
|
||||
@Composable
|
||||
fun ItemPresetFilterAction(
|
||||
presetTag: PresetTagKind,
|
||||
active: 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 = {
|
||||
onCloseMenuAction.value = {
|
||||
chatModel.activeChatTagFilter.value = ActiveFilter.PresetTag(presetTag)
|
||||
onCloseMenuAction.value = {}
|
||||
}
|
||||
showMenu.value = false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun filteredChats(
|
||||
searchShowingSimplexLink: State<Boolean>,
|
||||
searchChatFilteredBySimplexLink: State<String?>,
|
||||
searchText: String,
|
||||
chats: List<Chat>,
|
||||
activeFilter: ActiveFilter? = null,
|
||||
): List<Chat> {
|
||||
val linkChatId = searchChatFilteredBySimplexLink.value
|
||||
return if (linkChatId != null) {
|
||||
chats.filter { it.id == linkChatId }
|
||||
} else {
|
||||
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
|
||||
if (s.isEmpty())
|
||||
chats.filter { chat -> chat.id == chatModel.chatId.value || (!chat.chatInfo.chatDeleted && !chat.chatInfo.contactCard && filtered(chat, activeFilter)) }
|
||||
else {
|
||||
chats.filter { chat ->
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filtered(chat: Chat, activeFilter: ActiveFilter?): Boolean =
|
||||
when (activeFilter) {
|
||||
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, 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
|
||||
is ChatInfo.ContactRequest -> true
|
||||
is ChatInfo.ContactConnection -> true
|
||||
is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Customer
|
||||
else -> false
|
||||
}
|
||||
PresetTagKind.GROUPS -> when (chatInfo) {
|
||||
is ChatInfo.Group -> chatInfo.groupInfo.businessChat == null
|
||||
else -> false
|
||||
}
|
||||
PresetTagKind.BUSINESS -> when (chatInfo) {
|
||||
is ChatInfo.Group -> chatInfo.groupInfo.businessChat?.chatType == BusinessChatType.Business
|
||||
else -> false
|
||||
}
|
||||
PresetTagKind.NOTES -> when (chatInfo) {
|
||||
is ChatInfo.Local -> !chatInfo.noteFolder.chatDeleted
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun presetTagLabel(tag: PresetTagKind, active: Boolean): Pair<ImageResource, StringResource> =
|
||||
when (tag) {
|
||||
PresetTagKind.GROUP_REPORTS -> (if (active) MR.images.ic_flag_filled else MR.images.ic_flag) to MR.strings.chat_list_group_reports
|
||||
PresetTagKind.FAVORITES -> (if (active) MR.images.ic_star_filled else MR.images.ic_star) to MR.strings.chat_list_favorites
|
||||
PresetTagKind.CONTACTS -> (if (active) MR.images.ic_person_filled else MR.images.ic_person) to MR.strings.chat_list_contacts
|
||||
PresetTagKind.GROUPS -> (if (active) MR.images.ic_group_filled else MR.images.ic_group) to MR.strings.chat_list_groups
|
||||
PresetTagKind.BUSINESS -> (if (active) MR.images.ic_work_filled else MR.images.ic_work) to MR.strings.chat_list_businesses
|
||||
PresetTagKind.NOTES -> (if (active) MR.images.ic_folder_closed_filled else MR.images.ic_folder_closed) to MR.strings.chat_list_notes
|
||||
}
|
||||
|
||||
private fun presetCanBeCollapsed(tag: PresetTagKind): Boolean = when (tag) {
|
||||
PresetTagKind.GROUP_REPORTS -> false
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun scrollToBottom(scope: CoroutineScope, listState: LazyListState) {
|
||||
scope.launch { try { listState.animateScrollToItem(0) } catch (e: Exception) { Log.e(TAG, e.stackTraceToString()) } }
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.InlineTextContent
|
||||
|
@ -21,11 +22,13 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.input.pointer.pointerHoverIcon
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.*
|
||||
import androidx.compose.ui.unit.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chat.*
|
||||
|
@ -44,7 +47,8 @@ fun ChatPreviewView(
|
|||
disabled: Boolean,
|
||||
linkMode: SimplexLinkMode,
|
||||
inProgress: Boolean,
|
||||
progressByTimeout: Boolean
|
||||
progressByTimeout: Boolean,
|
||||
defaultClickAction: () -> Unit
|
||||
) {
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
|
@ -174,13 +178,23 @@ fun ChatPreviewView(
|
|||
val (text: CharSequence, inlineTextContent) = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) }
|
||||
ci.meta.itemDeleted == null -> ci.text to null
|
||||
else -> markedDeletedText(ci.meta) to null
|
||||
else -> markedDeletedText(ci, chat.chatInfo) to null
|
||||
}
|
||||
val formattedText = when {
|
||||
chatModelDraftChatId == chat.id && chatModelDraft != null -> null
|
||||
ci.meta.itemDeleted == null -> ci.formattedText
|
||||
else -> null
|
||||
}
|
||||
val prefix = when (val mc = ci.content.msgContent) {
|
||||
is MsgContent.MCReport ->
|
||||
buildAnnotatedString {
|
||||
withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) {
|
||||
append(if (text.isEmpty()) mc.reason.text else "${mc.reason.text}: ")
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
MarkdownText(
|
||||
text,
|
||||
formattedText,
|
||||
|
@ -202,6 +216,7 @@ fun ChatPreviewView(
|
|||
),
|
||||
inlineContent = inlineTextContent,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
prefix = prefix
|
||||
)
|
||||
}
|
||||
} else {
|
||||
|
@ -236,7 +251,38 @@ fun ChatPreviewView(
|
|||
val uriHandler = LocalUriHandler.current
|
||||
when (mc) {
|
||||
is MsgContent.MCLink -> SmallContentPreview {
|
||||
IconButton({ uriHandler.openUriCatching(mc.preview.uri) }, Modifier.desktopPointerHoverIconHand()) {
|
||||
val linkClicksEnabled = remember { appPrefs.privacyChatListOpenLinks.state }.value != PrivacyChatListOpenLinksMode.NO
|
||||
IconButton({
|
||||
when (appPrefs.privacyChatListOpenLinks.get()) {
|
||||
PrivacyChatListOpenLinksMode.YES -> uriHandler.openUriCatching(mc.preview.uri)
|
||||
PrivacyChatListOpenLinksMode.NO -> defaultClickAction()
|
||||
PrivacyChatListOpenLinksMode.ASK -> AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.privacy_chat_list_open_web_link_question),
|
||||
text = mc.preview.uri,
|
||||
buttons = {
|
||||
Column {
|
||||
if (chatModel.chatId.value != chat.id) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
defaultClickAction()
|
||||
}) {
|
||||
Text(stringResource(MR.strings.open_chat), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
uriHandler.openUriCatching(mc.preview.uri)
|
||||
}
|
||||
) {
|
||||
Text(stringResource(MR.strings.privacy_chat_list_open_web_link), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
if (linkClicksEnabled) Modifier.desktopPointerHoverIconHand() else Modifier,
|
||||
) {
|
||||
Image(base64ToBitmap(mc.preview.image), null, contentScale = ContentScale.Crop)
|
||||
}
|
||||
Box(Modifier.align(Alignment.TopEnd).size(15.sp.toDp()).background(Color.Black.copy(0.25f), CircleShape), contentAlignment = Alignment.Center) {
|
||||
|
@ -310,6 +356,8 @@ fun ChatPreviewView(
|
|||
} else if (cInfo is ChatInfo.Group) {
|
||||
if (progressByTimeout) {
|
||||
progressView()
|
||||
} else if (chat.chatStats.reportsCount > 0) {
|
||||
GroupReportsIcon()
|
||||
} else {
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
|
@ -457,6 +505,18 @@ fun IncognitoIcon(incognito: Boolean) {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GroupReportsIcon() {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_flag),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colors.error,
|
||||
modifier = Modifier
|
||||
.size(21.sp.toDp())
|
||||
.offset(x = 2.sp.toDp())
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun groupInvitationPreviewText(currentUserProfileDisplayName: String?, groupInfo: GroupInfo): String {
|
||||
return if (groupInfo.membership.memberIncognito)
|
||||
|
@ -501,6 +561,6 @@ private data class ActiveVoicePreview(
|
|||
@Composable
|
||||
fun PreviewChatPreviewView() {
|
||||
SimpleXTheme {
|
||||
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false)
|
||||
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), disabled = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false, {})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -191,7 +191,7 @@ private fun ShareList(
|
|||
val chats by remember(search) {
|
||||
derivedStateOf {
|
||||
val sorted = chatModel.chats.value.toList().filter { it.chatInfo.ready }.sortedByDescending { it.chatInfo is ChatInfo.Local }
|
||||
filteredChats(false, mutableStateOf(false), mutableStateOf(null), search, sorted)
|
||||
filteredChats(mutableStateOf(false), mutableStateOf(null), search, sorted)
|
||||
}
|
||||
}
|
||||
val topPaddingToContent = topPaddingToContent(false)
|
||||
|
|
|
@ -0,0 +1,508 @@
|
|||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionCustomFooter
|
||||
import SectionDivider
|
||||
import SectionItemView
|
||||
import TextIconSpaced
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.material.MaterialTheme.colors
|
||||
import androidx.compose.material.TextFieldDefaults.indicatorLine
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.*
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.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
|
||||
import chat.simplex.common.views.chat.item.ReactionIcon
|
||||
import chat.simplex.common.views.chat.topPaddingToContent
|
||||
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
|
||||
|
||||
@Composable
|
||||
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()
|
||||
val saving = remember { mutableStateOf(false) }
|
||||
val chatTagIds = derivedStateOf { chat?.chatInfo?.chatTags ?: emptyList() }
|
||||
|
||||
fun reorderTags(tagIds: List<Long>) {
|
||||
saving.value = true
|
||||
withBGApi {
|
||||
try {
|
||||
chatModel.controller.apiReorderChatTags(rhId, tagIds)
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "ChatListTag reorderTags error: ${e.message}")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val dragDropState =
|
||||
rememberDragDropState(listState) { fromIndex, toIndex ->
|
||||
userTags.value = userTags.value.toMutableList().apply { add(toIndex, removeAt(fromIndex)) }
|
||||
reorderTags(userTags.value.map { it.chatTagId })
|
||||
}
|
||||
val topPaddingToContent = topPaddingToContent(false)
|
||||
|
||||
LazyColumnWithScrollBar(
|
||||
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
|
||||
),
|
||||
verticalArrangement = if (oneHandUI.value) Arrangement.Bottom else Arrangement.Top,
|
||||
) {
|
||||
@Composable fun CreateList() {
|
||||
SectionItemView({
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
TagListEditor(rhId = rhId, close = close, chat = chat)
|
||||
}
|
||||
}) {
|
||||
Icon(painterResource(MR.images.ic_add), stringResource(MR.strings.create_list), tint = MaterialTheme.colors.primary)
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(stringResource(MR.strings.create_list), color = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
|
||||
if (oneHandUI.value && !reorderMode) {
|
||||
item {
|
||||
CreateList()
|
||||
}
|
||||
}
|
||||
itemsIndexed(userTags.value, key = { _, item -> item.chatTagId }) { index, tag ->
|
||||
DraggableItem(dragDropState, index) { isDragging ->
|
||||
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
|
||||
|
||||
Card(
|
||||
elevation = elevation,
|
||||
backgroundColor = if (isDragging) colors.surface else Color.Unspecified
|
||||
) {
|
||||
Column {
|
||||
val selected = chatTagIds.value.contains(tag.chatTagId)
|
||||
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
|
||||
.clickable(
|
||||
enabled = !saving.value && !reorderMode,
|
||||
onClick = {
|
||||
if (chat == null) {
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
TagListEditor(
|
||||
rhId = rhId,
|
||||
tagId = tag.chatTagId,
|
||||
close = close,
|
||||
emoji = tag.chatTagEmoji,
|
||||
name = tag.chatTagText,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
saving.value = true
|
||||
setTag(rhId = rhId, tagId = if (selected) null else tag.chatTagId, chat = chat, close = {
|
||||
saving.value = false
|
||||
close()
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
.padding(PaddingValues(horizontal = DEFAULT_PADDING, vertical = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL)),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (tag.chatTagEmoji != null) {
|
||||
ReactionIcon(tag.chatTagEmoji, fontSize = 14.sp)
|
||||
} else {
|
||||
Icon(painterResource(MR.images.ic_label), null, Modifier.size(18.sp.toDp()), tint = MaterialTheme.colors.onBackground)
|
||||
}
|
||||
Spacer(Modifier.padding(horizontal = 4.dp))
|
||||
Text(
|
||||
tag.chatTagText,
|
||||
color = MenuTextColor,
|
||||
fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal
|
||||
)
|
||||
if (selected) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(painterResource(MR.images.ic_done_filled), null, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
|
||||
} else if (reorderMode) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
Icon(painterResource(MR.images.ic_drag_handle), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
|
||||
}
|
||||
}
|
||||
SectionDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!oneHandUI.value && !reorderMode) {
|
||||
item {
|
||||
CreateList()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModalData.TagListEditor(
|
||||
rhId: Long?,
|
||||
chat: Chat? = null,
|
||||
tagId: Long? = null,
|
||||
emoji: String? = null,
|
||||
name: String = "",
|
||||
close: () -> Unit
|
||||
) {
|
||||
val userTags = remember { chatModel.userTags }
|
||||
val oneHandUI = remember { appPrefs.oneHandUI.state }
|
||||
val newEmoji = remember { stateGetOrPutNullable("chatTagEmoji") { emoji } }
|
||||
val newName = remember { stateGetOrPut("chatTagName") { name } }
|
||||
val saving = remember { mutableStateOf<Boolean?>(null) }
|
||||
val trimmedName = remember { derivedStateOf { newName.value.trim() } }
|
||||
val isDuplicateEmojiOrName = remember {
|
||||
derivedStateOf {
|
||||
userTags.value.any { tag ->
|
||||
tag.chatTagId != tagId &&
|
||||
((newEmoji.value != null && tag.chatTagEmoji == newEmoji.value) || tag.chatTagText == trimmedName.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun createTag() {
|
||||
saving.value = true
|
||||
withBGApi {
|
||||
try {
|
||||
val updatedTags = chatModel.controller.apiCreateChatTag(rhId, ChatTagData(newEmoji.value, trimmedName.value))
|
||||
if (updatedTags != null) {
|
||||
saving.value = false
|
||||
userTags.value = updatedTags
|
||||
close()
|
||||
} else {
|
||||
saving.value = null
|
||||
return@withBGApi
|
||||
}
|
||||
|
||||
if (chat != null) {
|
||||
val createdTag = updatedTags.firstOrNull() { it.chatTagText == trimmedName.value && it.chatTagEmoji == newEmoji.value }
|
||||
|
||||
if (createdTag != null) {
|
||||
setTag(rhId, createdTag.chatTagId, chat, close = {
|
||||
saving.value = false
|
||||
close()
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "createChatTag tag error: ${e.message}")
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateTag() {
|
||||
saving.value = true
|
||||
withBGApi {
|
||||
try {
|
||||
if (chatModel.controller.apiUpdateChatTag(rhId, tagId!!, ChatTagData(newEmoji.value, trimmedName.value))) {
|
||||
userTags.value = userTags.value.map { tag ->
|
||||
if (tag.chatTagId == tagId) {
|
||||
tag.copy(chatTagEmoji = newEmoji.value, chatTagText = trimmedName.value)
|
||||
} else {
|
||||
tag
|
||||
}
|
||||
}
|
||||
} else {
|
||||
saving.value = null
|
||||
return@withBGApi
|
||||
}
|
||||
saving.value = false
|
||||
close()
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "ChatListTagEditor updateChatTag tag error: ${e.message}")
|
||||
saving.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showError = derivedStateOf { isDuplicateEmojiOrName.value && saving.value != false }
|
||||
|
||||
ColumnWithScrollBar(Modifier.consumeWindowInsets(PaddingValues(bottom = if (oneHandUI.value) WindowInsets.ime.asPaddingValues().calculateBottomPadding().coerceIn(0.dp, WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()) else 0.dp))) {
|
||||
if (oneHandUI.value) {
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
ChatTagInput(newName, showError, newEmoji)
|
||||
val disabled = saving.value == true ||
|
||||
(trimmedName.value == name && newEmoji.value == emoji) ||
|
||||
trimmedName.value.isEmpty() ||
|
||||
isDuplicateEmojiOrName.value
|
||||
|
||||
SectionItemView(click = { if (tagId == null) createTag() else updateTag() }, disabled = disabled) {
|
||||
Text(
|
||||
generalGetString(if (chat != null) MR.strings.add_to_list else MR.strings.save_list),
|
||||
color = if (disabled) colors.secondary else colors.primary
|
||||
)
|
||||
}
|
||||
val showErrorMessage = isDuplicateEmojiOrName.value && saving.value != false
|
||||
SectionCustomFooter {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
contentDescription = stringResource(MR.strings.error),
|
||||
tint = if (showErrorMessage) Color.Red else Color.Transparent,
|
||||
modifier = Modifier
|
||||
.size(19.sp.toDp())
|
||||
.offset(x = 2.sp.toDp())
|
||||
)
|
||||
TextIconSpaced()
|
||||
Text(
|
||||
generalGetString(MR.strings.duplicated_list_error),
|
||||
color = if (showErrorMessage) colors.secondary else Color.Transparent,
|
||||
lineHeight = 18.sp,
|
||||
fontSize = 14.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
stringResource(MR.strings.delete_chat_list_menu_action),
|
||||
painterResource(MR.images.ic_delete),
|
||||
onClick = {
|
||||
deleteTagDialog(rhId, tag, saving)
|
||||
showMenu.value = false
|
||||
},
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EditTagAction(rhId: Long?, tag: ChatTag, showMenu: MutableState<Boolean>) {
|
||||
ItemAction(
|
||||
stringResource(MR.strings.edit_chat_list_menu_action),
|
||||
painterResource(MR.images.ic_edit),
|
||||
onClick = {
|
||||
showMenu.value = false
|
||||
ModalManager.start.showModalCloseable { close ->
|
||||
TagListEditor(
|
||||
rhId = rhId,
|
||||
tagId = tag.chatTagId,
|
||||
close = close,
|
||||
emoji = tag.chatTagEmoji,
|
||||
name = tag.chatTagText
|
||||
)
|
||||
}
|
||||
},
|
||||
color = MenuTextColor
|
||||
)
|
||||
}
|
||||
|
||||
@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?>)
|
||||
|
||||
@Composable
|
||||
fun TagListNameTextField(name: MutableState<String>, showError: State<Boolean>) {
|
||||
var focused by rememberSaveable { mutableStateOf(false) }
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val colors = TextFieldDefaults.textFieldColors(
|
||||
backgroundColor = Color.Unspecified,
|
||||
focusedIndicatorColor = MaterialTheme.colors.secondary.copy(alpha = 0.6f),
|
||||
unfocusedIndicatorColor = CurrentColors.value.colors.secondary.copy(alpha = 0.3f),
|
||||
cursorColor = MaterialTheme.colors.secondary,
|
||||
)
|
||||
BasicTextField(
|
||||
value = name.value,
|
||||
onValueChange = { name.value = it },
|
||||
interactionSource = interactionSource,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.indicatorLine(true, showError.value, interactionSource, colors)
|
||||
.heightIn(min = TextFieldDefaults.MinHeight)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(fontSize = 18.sp, color = MaterialTheme.colors.onBackground),
|
||||
singleLine = true,
|
||||
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
|
||||
decorationBox = @Composable { innerTextField ->
|
||||
TextFieldDefaults.TextFieldDecorationBox(
|
||||
value = name.value,
|
||||
innerTextField = innerTextField,
|
||||
placeholder = {
|
||||
Text(generalGetString(MR.strings.list_name_field_placeholder), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary, lineHeight = 22.sp))
|
||||
},
|
||||
contentPadding = PaddingValues(),
|
||||
label = null,
|
||||
visualTransformation = VisualTransformation.None,
|
||||
leadingIcon = null,
|
||||
singleLine = true,
|
||||
enabled = true,
|
||||
isError = false,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
colors = TextFieldDefaults.textFieldColors(backgroundColor = Color.Unspecified)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun setTag(rhId: Long?, tagId: Long?, chat: Chat, close: () -> Unit) {
|
||||
withBGApi {
|
||||
val tagIds: List<Long> = if (tagId == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
listOf(tagId)
|
||||
}
|
||||
|
||||
try {
|
||||
val result = apiSetChatTags(rh = rhId, type = chat.chatInfo.chatType, id = chat.chatInfo.apiId, tagIds = tagIds)
|
||||
|
||||
if (result != null) {
|
||||
val oldTags = chat.chatInfo.chatTags
|
||||
chatModel.userTags.value = result.first
|
||||
when (val cInfo = chat.chatInfo) {
|
||||
is ChatInfo.Direct -> {
|
||||
val contact = cInfo.contact.copy(chatTags = result.second)
|
||||
withChats {
|
||||
updateContact(rhId, contact)
|
||||
}
|
||||
}
|
||||
|
||||
is ChatInfo.Group -> {
|
||||
val group = cInfo.groupInfo.copy(chatTags = result.second)
|
||||
withChats {
|
||||
updateGroup(rhId, group)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
chatModel.moveChatTagUnread(chat, oldTags, result.second)
|
||||
close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "setChatTag error: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteTag(rhId: Long?, tag: ChatTag, saving: MutableState<Boolean>) {
|
||||
withBGApi {
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
val tagId = tag.chatTagId
|
||||
if (apiDeleteChatTag(rhId, tagId)) {
|
||||
chatModel.userTags.value = chatModel.userTags.value.filter { it.chatTagId != tagId }
|
||||
if (chatModel.activeChatTagFilter.value == ActiveFilter.UserTag(tag)) {
|
||||
chatModel.activeChatTagFilter.value = null
|
||||
}
|
||||
chatModel.chats.value.forEach { c ->
|
||||
when (val cInfo = c.chatInfo) {
|
||||
is ChatInfo.Direct -> {
|
||||
val contact = cInfo.contact.copy(chatTags = cInfo.contact.chatTags.filter { it != tagId })
|
||||
withChats {
|
||||
updateContact(rhId, contact)
|
||||
}
|
||||
}
|
||||
is ChatInfo.Group -> {
|
||||
val group = cInfo.groupInfo.copy(chatTags = cInfo.groupInfo.chatTags.filter { it != tagId })
|
||||
withChats {
|
||||
updateGroup(rhId, group)
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "deleteTag error: ${e.message}")
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteTagDialog(rhId: Long?, tag: ChatTag, saving: MutableState<Boolean>) {
|
||||
AlertManager.shared.showAlertDialogButtonsColumn(
|
||||
title = generalGetString(MR.strings.delete_chat_list_question),
|
||||
text = String.format(generalGetString(MR.strings.delete_chat_list_warning), tag.chatTagText),
|
||||
buttons = {
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
deleteTag(rhId, tag, saving)
|
||||
}) {
|
||||
Text(
|
||||
generalGetString(MR.strings.confirm_verb),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = colors.error
|
||||
)
|
||||
}
|
||||
SectionItemView({
|
||||
AlertManager.shared.hideAlert()
|
||||
}) {
|
||||
Text(
|
||||
stringResource(MR.strings.cancel_verb),
|
||||
Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = colors.primary
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
|
@ -21,7 +21,9 @@ fun onRequestAccepted(chat: Chat) {
|
|||
if (chatInfo is ChatInfo.Direct) {
|
||||
ModalManager.start.closeModals()
|
||||
if (chatInfo.contact.sndReady) {
|
||||
openLoadedChat(chat)
|
||||
withApi {
|
||||
openLoadedChat(chat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,6 +21,7 @@ import chat.simplex.common.model.*
|
|||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel.controller
|
||||
import chat.simplex.common.model.ChatModel.withChats
|
||||
import chat.simplex.common.model.ChatModel.withReportsChatsIfOpen
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.views.usersettings.*
|
||||
|
@ -528,9 +529,14 @@ fun deleteChatDatabaseFilesAndState() {
|
|||
|
||||
// Clear sensitive data on screen just in case ModalManager will fail to prevent hiding its modals while database encrypts itself
|
||||
chatModel.chatId.value = null
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
withLongRunningApi {
|
||||
withChats {
|
||||
chatItems.clearAndNotify()
|
||||
chats.clear()
|
||||
popChatCollector.clear()
|
||||
}
|
||||
withReportsChatsIfOpen {
|
||||
chatItems.clearAndNotify()
|
||||
chats.clear()
|
||||
popChatCollector.clear()
|
||||
}
|
||||
|
|
|
@ -5,8 +5,7 @@ import androidx.compose.foundation.layout.*
|
|||
import androidx.compose.foundation.shape.CornerSize
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
@ -16,6 +15,7 @@ fun DefaultDropdownMenu(
|
|||
showMenu: MutableState<Boolean>,
|
||||
modifier: Modifier = Modifier,
|
||||
offset: DpOffset = DpOffset(0.dp, 0.dp),
|
||||
onClosed: State<() -> Unit> = remember { mutableStateOf({}) },
|
||||
dropdownMenuItems: (@Composable () -> Unit)?
|
||||
) {
|
||||
MaterialTheme(
|
||||
|
@ -31,6 +31,11 @@ fun DefaultDropdownMenu(
|
|||
offset = offset,
|
||||
) {
|
||||
dropdownMenuItems?.invoke()
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
onClosed.value()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,7 +35,8 @@ fun DefaultAppBar(
|
|||
// If I just disable clickable modifier when don't need it, it will stop passing clicks to search. Replacing the whole modifier
|
||||
val modifier = if (!showSearch) {
|
||||
Modifier.clickable(enabled = onTitleClick != null, onClick = onTitleClick ?: { })
|
||||
} else Modifier.imePadding()
|
||||
} else if (!onTop) Modifier.imePadding()
|
||||
else Modifier
|
||||
|
||||
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
|
||||
val prefAlpha = remember { appPrefs.inAppBarsAlpha.state }
|
||||
|
|
|
@ -0,0 +1,177 @@
|
|||
package chat.simplex.common.views.helpers
|
||||
|
||||
/*
|
||||
* This was adapted from google example of drag and drop for Jetpack Compose
|
||||
* https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/foundation/foundation/integration-tests/foundation-demos/src/main/java/androidx/compose/foundation/demos/LazyColumnDragAndDropDemo.kt
|
||||
*/
|
||||
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.lazy.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun rememberDragDropState(lazyListState: LazyListState, onMove: (Int, Int) -> Unit): DragDropState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state =
|
||||
remember(lazyListState) {
|
||||
DragDropState(state = lazyListState, onMove = onMove, scope = scope)
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
val diff = state.scrollChannel.receive()
|
||||
lazyListState.scrollBy(diff)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
class DragDropState
|
||||
internal constructor(
|
||||
private val state: LazyListState,
|
||||
private val scope: CoroutineScope,
|
||||
private val onMove: (Int, Int) -> Unit
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal val scrollChannel = Channel<Float>()
|
||||
|
||||
private var draggingItemDraggedDelta by mutableFloatStateOf(0f)
|
||||
private var draggingItemInitialOffset by mutableIntStateOf(0)
|
||||
internal val draggingItemOffset: Float
|
||||
get() =
|
||||
draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset
|
||||
} ?: 0f
|
||||
|
||||
private val draggingItemLayoutInfo: LazyListItemInfo?
|
||||
get() = state.layoutInfo.visibleItemsInfo.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal var previousItemOffset = Animatable(0f)
|
||||
private set
|
||||
|
||||
internal fun onDragStart(offset: Offset) {
|
||||
val touchY = offset.y.toInt()
|
||||
val item = state.layoutInfo.visibleItemsInfo.minByOrNull {
|
||||
val itemCenter = (it.offset - state.layoutInfo.viewportStartOffset) + it.size / 2
|
||||
kotlin.math.abs(touchY - itemCenter) // Find the item closest to the touch position, needs to take viewportStartOffset into account
|
||||
}
|
||||
|
||||
if (item != null) {
|
||||
draggingItemIndex = item.index
|
||||
draggingItemInitialOffset = item.offset
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal fun onDragInterrupted() {
|
||||
if (draggingItemIndex != null) {
|
||||
previousIndexOfDraggedItem = draggingItemIndex
|
||||
val startOffset = draggingItemOffset
|
||||
scope.launch {
|
||||
previousItemOffset.snapTo(startOffset)
|
||||
previousItemOffset.animateTo(
|
||||
0f,
|
||||
spring(stiffness = Spring.StiffnessMediumLow, visibilityThreshold = 1f)
|
||||
)
|
||||
previousIndexOfDraggedItem = null
|
||||
}
|
||||
}
|
||||
draggingItemDraggedDelta = 0f
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = 0
|
||||
}
|
||||
|
||||
internal fun onDrag(offset: Offset) {
|
||||
draggingItemDraggedDelta += offset.y
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset + draggingItemOffset
|
||||
val endOffset = startOffset + draggingItem.size
|
||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||
|
||||
val targetItem =
|
||||
state.layoutInfo.visibleItemsInfo.find { item ->
|
||||
middleOffset.toInt() in item.offset..item.offsetEnd &&
|
||||
draggingItem.index != item.index
|
||||
}
|
||||
if (targetItem != null) {
|
||||
if (
|
||||
draggingItem.index == state.firstVisibleItemIndex ||
|
||||
targetItem.index == state.firstVisibleItemIndex
|
||||
) {
|
||||
state.requestScrollToItem(
|
||||
state.firstVisibleItemIndex,
|
||||
state.firstVisibleItemScrollOffset
|
||||
)
|
||||
}
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
draggingItemIndex = targetItem.index
|
||||
} else {
|
||||
val overscroll =
|
||||
when {
|
||||
draggingItemDraggedDelta > 0 ->
|
||||
(endOffset - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||
draggingItemDraggedDelta < 0 ->
|
||||
(startOffset - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||
else -> 0f
|
||||
}
|
||||
if (overscroll != 0f) {
|
||||
scrollChannel.trySend(overscroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LazyListItemInfo.offsetEnd: Int
|
||||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
fun Modifier.dragContainer(dragDropState: DragDropState): Modifier {
|
||||
return pointerInput(dragDropState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset = offset)
|
||||
},
|
||||
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LazyItemScope.DraggableItem(
|
||||
dragDropState: DragDropState,
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.(isDragging: Boolean) -> Unit
|
||||
) {
|
||||
val dragging = index == dragDropState.draggingItemIndex
|
||||
val draggingModifier =
|
||||
if (dragging) {
|
||||
Modifier.zIndex(1f).graphicsLayer { translationY = dragDropState.draggingItemOffset }
|
||||
} else if (index == dragDropState.previousIndexOfDraggedItem) {
|
||||
Modifier.zIndex(1f).graphicsLayer {
|
||||
translationY = dragDropState.previousItemOffset.value
|
||||
}
|
||||
} else {
|
||||
Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
|
||||
}
|
||||
Column(modifier = modifier.then(draggingModifier)) { content(dragging) }
|
||||
}
|
|
@ -51,7 +51,7 @@ fun authenticateWithPasscode(
|
|||
close()
|
||||
completed(LAResult.Error(generalGetString(MR.strings.authentication_cancelled)))
|
||||
}
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
|
||||
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colors.background.copy(1f), contentColor = LocalContentColor.current) {
|
||||
LocalAuthView(ChatModel, LocalAuthRequest(promptTitle, promptSubtitle, password, selfDestruct && ChatController.appPrefs.selfDestruct.get()) {
|
||||
close()
|
||||
completed(it)
|
||||
|
|
|
@ -77,8 +77,19 @@ class ModalData(val keyboardCoversBar: Boolean = true) {
|
|||
val appBarHandler = AppBarHandler(null, null, keyboardCoversBar = keyboardCoversBar)
|
||||
}
|
||||
|
||||
enum class ModalViewId {
|
||||
GROUP_REPORTS
|
||||
}
|
||||
|
||||
class ModalManager(private val placement: ModalPlacement? = null) {
|
||||
private val modalViews = arrayListOf<Triple<Boolean, ModalData, (@Composable ModalData.(close: () -> Unit) -> Unit)>>()
|
||||
data class ModalViewHolder(
|
||||
val id: ModalViewId?,
|
||||
val animated: Boolean,
|
||||
val data: ModalData,
|
||||
val modal: @Composable ModalData.(close: () -> Unit) -> Unit
|
||||
)
|
||||
|
||||
private val modalViews = arrayListOf<ModalViewHolder>()
|
||||
private val _modalCount = mutableStateOf(0)
|
||||
val modalCount: State<Int> = _modalCount
|
||||
private val toRemove = mutableSetOf<Int>()
|
||||
|
@ -88,19 +99,23 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
|||
private var passcodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
|
||||
private var onTimePasscodeView: MutableStateFlow<(@Composable (close: () -> Unit) -> Unit)?> = MutableStateFlow(null)
|
||||
|
||||
fun showModal(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
|
||||
showCustomModal { close ->
|
||||
fun hasModalOpen(id: ModalViewId): Boolean = modalViews.any { it.id == id }
|
||||
|
||||
fun isLastModalOpen(id: ModalViewId): Boolean = modalViews.lastOrNull()?.id == id
|
||||
|
||||
fun showModal(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.() -> Unit) {
|
||||
showCustomModal(id = id) { close ->
|
||||
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content() })
|
||||
}
|
||||
}
|
||||
|
||||
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
showCustomModal { close ->
|
||||
fun showModalCloseable(settings: Boolean = false, showClose: Boolean = true, id: ModalViewId? = null, endButtons: @Composable RowScope.() -> Unit = {}, content: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
showCustomModal(id = id) { close ->
|
||||
ModalView(close, showClose = showClose, endButtons = endButtons, content = { content(close) })
|
||||
}
|
||||
}
|
||||
|
||||
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
fun showCustomModal(animated: Boolean = true, keyboardCoversBar: Boolean = true, id: ModalViewId? = null, modal: @Composable ModalData.(close: () -> Unit) -> Unit) {
|
||||
Log.d(TAG, "ModalManager.showCustomModal")
|
||||
val data = ModalData(keyboardCoversBar = keyboardCoversBar)
|
||||
// Means, animation is in progress or not started yet. Do not wait until animation finishes, just remove all from screen.
|
||||
|
@ -111,7 +126,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
|||
// Make animated appearance only on Android (everytime) and on Desktop (when it's on the start part of the screen or modals > 0)
|
||||
// to prevent unneeded animation on different situations
|
||||
val anim = if (appPlatform.isAndroid) animated else animated && (modalCount.value > 0 || placement == ModalPlacement.START)
|
||||
modalViews.add(Triple(anim, data, modal))
|
||||
modalViews.add(ModalViewHolder(id, anim, data, modal))
|
||||
_modalCount.value = modalViews.size - toRemove.size
|
||||
|
||||
if (placement == ModalPlacement.CENTER) {
|
||||
|
@ -139,7 +154,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
|||
|
||||
fun closeModal() {
|
||||
if (modalViews.isNotEmpty()) {
|
||||
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
|
||||
if (modalViews.lastOrNull()?.animated == false) modalViews.removeAt(modalViews.lastIndex)
|
||||
else runAtomically { toRemove.add(modalViews.lastIndex - min(toRemove.size, modalViews.lastIndex)) }
|
||||
}
|
||||
_modalCount.value = modalViews.size - toRemove.size
|
||||
|
@ -161,10 +176,10 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
|||
@Composable
|
||||
fun showInView() {
|
||||
// Without animation
|
||||
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
|
||||
if (modalCount.value > 0 && modalViews.lastOrNull()?.animated == false) {
|
||||
modalViews.lastOrNull()?.let {
|
||||
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
|
||||
it.third(it.second, ::closeModal)
|
||||
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) {
|
||||
it.modal(it.data, ::closeModal)
|
||||
}
|
||||
}
|
||||
return
|
||||
|
@ -179,8 +194,8 @@ class ModalManager(private val placement: ModalPlacement? = null) {
|
|||
}
|
||||
) {
|
||||
modalViews.getOrNull(it - 1)?.let {
|
||||
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.second.appBarHandler)) {
|
||||
it.third(it.second, ::closeModal)
|
||||
CompositionLocalProvider(LocalAppBarHandler provides adjustAppBarHandler(it.data.appBarHandler)) {
|
||||
it.modal(it.data, ::closeModal)
|
||||
}
|
||||
}
|
||||
// This is needed because if we delete from modalViews immediately on request, animation will be bad
|
||||
|
|
|
@ -10,6 +10,7 @@ import androidx.compose.ui.unit.*
|
|||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.ThemeModeOverrides
|
||||
import chat.simplex.common.ui.theme.ThemeOverrides
|
||||
import chat.simplex.common.views.chatlist.connectIfOpenedViaUri
|
||||
import chat.simplex.res.MR
|
||||
|
@ -246,13 +247,26 @@ fun saveAnimImage(uri: URI): CryptoFile? {
|
|||
|
||||
expect suspend fun saveTempImageUncompressed(image: ImageBitmap, asPng: Boolean): File?
|
||||
|
||||
fun saveFileFromUri(uri: URI, withAlertOnException: Boolean = true): CryptoFile? {
|
||||
fun saveFileFromUri(
|
||||
uri: URI,
|
||||
withAlertOnException: Boolean = true,
|
||||
hiddenFileNamePrefix: String? = null
|
||||
): CryptoFile? {
|
||||
return try {
|
||||
val encrypted = chatController.appPrefs.privacyEncryptLocalFiles.get()
|
||||
val inputStream = uri.inputStream()
|
||||
val fileToSave = getFileName(uri)
|
||||
return if (inputStream != null && fileToSave != null) {
|
||||
val destFileName = uniqueCombine(fileToSave, File(getAppFilePath("")))
|
||||
val destFileName = if (hiddenFileNamePrefix == null) {
|
||||
uniqueCombine(fileToSave, File(getAppFilePath("")))
|
||||
} else {
|
||||
val ext = when {
|
||||
// remove everything but extension
|
||||
fileToSave.contains(".") -> fileToSave.substringAfterLast(".")
|
||||
else -> null
|
||||
}
|
||||
generateNewFileName(hiddenFileNamePrefix, ext, File(getAppFilePath("")))
|
||||
}
|
||||
val destFile = File(getAppFilePath(destFileName))
|
||||
if (encrypted) {
|
||||
createTmpFileAndDelete { tmpFile ->
|
||||
|
@ -316,8 +330,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 {
|
||||
|
@ -327,11 +366,12 @@ fun <T> createTmpFileAndDelete(onCreated: (File) -> T): T {
|
|||
}
|
||||
}
|
||||
|
||||
fun generateNewFileName(prefix: String, ext: String, dir: File): String {
|
||||
fun generateNewFileName(prefix: String, ext: String?, dir: File): String {
|
||||
val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||
sdf.timeZone = TimeZone.getTimeZone("GMT")
|
||||
val timestamp = sdf.format(Date())
|
||||
return uniqueCombine("${prefix}_$timestamp.$ext", dir)
|
||||
val extension = if (ext != null) ".$ext" else ""
|
||||
return uniqueCombine("${prefix}_$timestamp$extension", dir)
|
||||
}
|
||||
|
||||
fun uniqueCombine(fileName: String, dir: File): String {
|
||||
|
|
|
@ -174,7 +174,7 @@ private fun SectionByState(
|
|||
is MigrationFromState.UploadProgress -> migrationState.UploadProgressView(s.uploadedBytes, s.totalBytes, s.ctrl, s.user, tempDatabaseFile, chatReceiver, s.archivePath)
|
||||
is MigrationFromState.UploadFailed -> migrationState.UploadFailedView(s.totalBytes, s.archivePath, chatReceiver.value)
|
||||
is MigrationFromState.LinkCreation -> LinkCreationView()
|
||||
is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl)
|
||||
is MigrationFromState.LinkShown -> migrationState.LinkShownView(s.fileId, s.link, s.ctrl, chatReceiver.value)
|
||||
is MigrationFromState.Finished -> migrationState.FinishedView(s.chatDeletion)
|
||||
}
|
||||
}
|
||||
|
@ -335,7 +335,7 @@ private fun LinkCreationView() {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl) {
|
||||
private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: String, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) {
|
||||
SectionView {
|
||||
SettingsActionItemWithContent(
|
||||
icon = painterResource(MR.images.ic_close),
|
||||
|
@ -356,7 +356,7 @@ private fun MutableState<MigrationFromState>.LinkShownView(fileId: Long, link: S
|
|||
confirmText = generalGetString(MR.strings.continue_to_next_step),
|
||||
destructive = true,
|
||||
onConfirm = {
|
||||
finishMigration(fileId, ctrl)
|
||||
finishMigration(fileId, ctrl, chatReceiver)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
@ -450,6 +450,7 @@ private fun MutableState<MigrationFromState>.stopChat() {
|
|||
try {
|
||||
controller.apiSaveAppSettings(AppSettings.current.prepareForExport())
|
||||
state = if (appPreferences.initialRandomDBPassphrase.get()) MigrationFromState.PassphraseNotSet else MigrationFromState.PassphraseConfirmation
|
||||
platform.androidChatStopped()
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.migrate_from_device_error_saving_settings),
|
||||
|
@ -617,9 +618,11 @@ private fun cancelMigration(fileId: Long, ctrl: ChatCtrl) {
|
|||
}
|
||||
}
|
||||
|
||||
private fun MutableState<MigrationFromState>.finishMigration(fileId: Long, ctrl: ChatCtrl) {
|
||||
private fun MutableState<MigrationFromState>.finishMigration(fileId: Long, ctrl: ChatCtrl, chatReceiver: MigrationFromChatReceiver?) {
|
||||
withBGApi {
|
||||
cancelUploadedArchive(fileId, ctrl)
|
||||
chatReceiver?.stopAndCleanUp()
|
||||
getMigrationTempFilesDirectory().deleteRecursively()
|
||||
state = MigrationFromState.Finished(false)
|
||||
}
|
||||
}
|
||||
|
@ -655,6 +658,7 @@ private suspend fun startChatAndDismiss(dismiss: Boolean = true) {
|
|||
} else if (user != null) {
|
||||
startChat(user)
|
||||
}
|
||||
platform.androidChatStartedAfterBeingOff()
|
||||
} catch (e: Exception) {
|
||||
AlertManager.shared.showAlertMsg(
|
||||
title = generalGetString(MR.strings.error_starting_chat),
|
||||
|
|
|
@ -691,6 +691,7 @@ private suspend fun finishMigration(appSettings: AppSettings, close: () -> Unit)
|
|||
if (user != null) {
|
||||
startChat(user)
|
||||
}
|
||||
platform.androidChatStartedAfterBeingOff()
|
||||
hideView(close)
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.migrate_to_device_chat_migrated), generalGetString(MR.strings.migrate_to_device_finalize_migration))
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -44,8 +44,8 @@ fun AddGroupView(chatModel: ChatModel, rh: RemoteHostInfo?, close: () -> Unit, c
|
|||
if (groupInfo != null) {
|
||||
withChats {
|
||||
updateGroup(rhId = rhId, groupInfo)
|
||||
chatModel.chatItems.clearAndNotify()
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatItems.clearAndNotify()
|
||||
chatItemStatuses.clear()
|
||||
chatModel.chatId.value = groupInfo.id
|
||||
}
|
||||
setGroupMembers(rhId, groupInfo, chatModel)
|
||||
|
|
|
@ -16,6 +16,8 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chat.simplex.common.model.ChatController.appPrefs
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.model.ChatController.setConditionsNotified
|
||||
import chat.simplex.common.model.ServerOperator.Companion.dummyOperatorInfo
|
||||
|
@ -766,7 +768,9 @@ private val versionDescriptions: List<VersionDescription> = listOf(
|
|||
private val lastVersion = versionDescriptions.last().version
|
||||
|
||||
fun setLastVersionDefault(m: ChatModel) {
|
||||
m.controller.appPrefs.whatsNewVersion.set(lastVersion)
|
||||
if (appPrefs.whatsNewVersion.get() != lastVersion) {
|
||||
appPrefs.whatsNewVersion.set(lastVersion)
|
||||
}
|
||||
}
|
||||
|
||||
fun shouldShowWhatsNew(m: ChatModel): Boolean {
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue