Merge branch 'master' into av/node-addon

This commit is contained in:
Evgeny Poberezkin 2025-01-21 11:48:18 +00:00
commit cdc135cafa
No known key found for this signature in database
GPG key ID: 494BDDD9A28B577D
326 changed files with 10489 additions and 4168 deletions

View file

@ -89,7 +89,12 @@ jobs:
cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps:
- name: Skip unreliable ghc 8.10.7 build on stable branch
if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable'
run: exit 0
- name: Configure pagefile (Windows)
if: matrix.os == 'windows-latest'
uses: al-cheb/configure-pagefile-action@v1.3

View file

@ -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 } }

View file

@ -15,12 +15,6 @@ import SimpleXChat
private var chatController: chat_ctrl?
// currentChatVersion in core
public let CURRENT_CHAT_VERSION: Int = 2
// version range that supports establishing direct connection with a group member (xGrpDirectInvVRange in core)
public let CREATE_MEMBER_CONTACT_VRANGE = VersionRange(minVersion: 2, maxVersion: CURRENT_CHAT_VERSION)
private let networkStatusesLock = DispatchQueue(label: "chat.simplex.app.network-statuses.lock")
enum TerminalItem: Identifiable {
@ -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 {

View file

@ -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: {}
)
}
}

View file

@ -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(

View file

@ -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: ()
}
}

View file

@ -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)
}

View file

@ -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))
}

View file

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

View file

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

View file

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

View file

@ -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))")

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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: ""
)
}
}

View file

@ -296,7 +296,7 @@ struct GroupMemberInfoView: View {
} else if groupInfo.fullGroupPreferences.directMessages.on(for: groupInfo.membership) {
if let contactId = member.memberContactId {
newDirectChatButton(contactId, width: buttonWidth)
} else if member.activeConn?.peerChatVRange.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) ?? false {
} else if member.versionRange.maxVersion >= CREATE_MEMBER_CONTACT_VERSION {
createMemberContactButton(member, width: buttonWidth)
}
InfoViewButton(image: "phone.fill", title: "call", disabledLook: true, width: buttonWidth) { showSendMessageToEnableCallsAlert()

View file

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

View file

@ -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"),

View file

@ -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
}
}
}

View file

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

View 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)
)
}
}
}
}
}

View file

@ -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)

View file

@ -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")
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View 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
}
}

View file

@ -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) } }
}
}

View file

@ -167,9 +167,9 @@
648010AB281ADD15009009B9 /* CIFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648010AA281ADD15009009B9 /* CIFileView.swift */; };
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
649B28DD2CFE07CF00536B68 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 649B28D82CFE07CF00536B68 /* libffi.a */; };
649B28DE2CFE07CF00536B68 /* libHSsimplex-chat-6.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;

View file

@ -51,6 +51,7 @@ public enum ChatCommand {
case apiUpdateChatTag(tagId: Int64, tagData: ChatTagData)
case apiReorderChatTags(tagIds: [Int64])
case apiCreateChatItems(noteFolderId: Int64, composedMessages: [ComposedMessage])
case apiReportMessage(groupId: Int64, chatItemId: Int64, reportReason: ReportReason, reportText: String)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
@ -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,

View file

@ -9,6 +9,14 @@
import Foundation
import SwiftUI
// version to establishing direct connection with a group member (xGrpDirectInvVRange in core)
public let CREATE_MEMBER_CONTACT_VERSION = 2
// version to receive reports (MCReport)
public let REPORTS_VERSION = 12
public let contentModerationPostLink = URL(string: "https://simplex.chat/blog/20250114-simplex-network-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

View file

@ -41,7 +41,7 @@ public func getDocumentsDirectory() -> URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
}
func getGroupContainerDirectory() -> URL {
public func getGroupContainerDirectory() -> URL {
FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP_NAME)!
}

View file

@ -267,17 +267,26 @@ public func saveWallpaperFile(image: UIImage) -> String? {
public func removeWallpaperFile(fileName: String? = nil) {
do {
try FileManager.default.contentsOfDirectory(atPath: getWallpaperDirectory().path).forEach {
if URL(fileURLWithPath: $0).lastPathComponent == fileName { try FileManager.default.removeItem(atPath: $0) }
try FileManager.default.contentsOfDirectory(at: URL(fileURLWithPath: getWallpaperDirectory().path), includingPropertiesForKeys: nil, options: []).forEach { url in
if url.lastPathComponent == fileName {
try FileManager.default.removeItem(at: url)
}
}
} catch {
logger.error("FileUtils.removeWallpaperFile error: \(error.localizedDescription)")
logger.error("FileUtils.removeWallpaperFile error: \(error)")
}
if let fileName {
WallpaperType.cachedImages.removeValue(forKey: fileName)
}
}
public func removeWallpaperFilesFromTheme(_ theme: ThemeModeOverrides?) {
if let theme {
removeWallpaperFile(fileName: theme.light?.wallpaper?.imageFile)
removeWallpaperFile(fileName: theme.dark?.wallpaper?.imageFile)
}
}
public func generateNewFileName(_ prefix: String, _ ext: String, fullPath: Bool = false) -> String {
uniqueCombine("\(prefix)_\(getTimestamp()).\(ext)", fullPath: fullPath)
}

View file

@ -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"

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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()
}

View file

@ -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)

View file

@ -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"

View file

@ -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"

View file

@ -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
) {

View file

@ -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) {

View file

@ -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
}

View file

@ -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
}
)
}
}

View file

@ -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) }

View file

@ -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)
}
}
}
)
}
}

View file

@ -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 {

View file

@ -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) {}
}
}

View file

@ -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

View file

@ -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(),

View file

@ -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) =

View file

@ -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
)

View file

@ -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

View file

@ -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(

View file

@ -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 ->

View file

@ -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
)

View file

@ -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
}
}

View file

@ -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

View file

@ -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
)
}
}
}
}

View file

@ -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]

View file

@ -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
}
}

View file

@ -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) }

View file

@ -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),

View file

@ -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 = {},
)
}

View file

@ -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)
}
}
}

View file

@ -45,6 +45,9 @@ fun GroupPreferencesView(m: ChatModel, rhId: Long?, chatId: String, close: () ->
updateGroup(rhId, g)
currentPreferences = preferences
}
withChats {
updateGroup(rhId, g)
}
}
afterSave()
}

View file

@ -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.*

View file

@ -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))
}

View file

@ -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

View file

@ -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)) {

View file

@ -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? ->

View file

@ -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

View file

@ -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)

View 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 ->

View file

@ -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)

View file

@ -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
)
}
}

View file

@ -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 ->

View file

@ -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) {

View file

@ -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 = {},

View file

@ -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()) } }

View file

@ -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, {})
}
}

View file

@ -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)

View file

@ -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
)
}
}
)
}

View file

@ -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)
}
}
}
}

View file

@ -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()
}

View file

@ -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()
}
}
}
}
}

View file

@ -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 }

View file

@ -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) }
}

View file

@ -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)

View file

@ -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

View file

@ -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 {

View file

@ -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),

View file

@ -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) {

View file

@ -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)

View file

@ -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