Merge branch 'master' into master-ghc8107

This commit is contained in:
Evgeny Poberezkin 2023-11-04 13:02:08 +00:00
commit 2de111e76c
98 changed files with 2499 additions and 968 deletions

View File

@ -55,7 +55,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
logger.debug("AppDelegate: didReceiveRemoteNotification")
print("*** userInfo", userInfo)
let m = ChatModel.shared
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
m.notificationMode != .off {

View File

@ -64,7 +64,7 @@ final class ChatModel: ObservableObject {
@Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GroupMember] = []
@Published var groupMembers: [GMember] = []
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@ -163,6 +163,10 @@ final class ChatModel: ObservableObject {
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
groupMembers.first { $0.groupMemberId == groupMemberId }
}
private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id })
}
@ -176,6 +180,7 @@ final class ChatModel: ObservableObject {
func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) {
chats[i].chatInfo = cInfo
chats[i].created = Date.now
}
}
@ -339,7 +344,7 @@ final class ChatModel: ObservableObject {
reversedChatItems[i].viewTimestamp = .now
}
private func getChatItemIndex(_ cItem: ChatItem) -> Int? {
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
@ -528,27 +533,62 @@ final class ChatModel: ObservableObject {
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
}
func getConnectedMemberNames(_ ci: ChatItem) -> [String] {
guard var i = getChatItemIndex(ci) else { return [] }
// this function analyses "connected" events and assumes that each member will be there only once
func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) {
var count = 0
var ns: [String] = []
while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected {
ns.append(m.displayName)
i += 1
if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) {
while i < reversedChatItems.count {
let ci = reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected {
ns.append(m.displayName)
}
count += 1
i += 1
}
}
return ns
return (count, ns)
}
func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) {
if let i = getChatItemIndex(ci) {
return (
i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil,
i - 1 >= 0 ? reversedChatItems[i - 1] : nil
)
// returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) {
(i, i > 0 ? reversedChatItems[i - 1] : nil)
} else {
return (nil, nil)
(nil, nil)
}
}
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) }
let fst = reversedChatItems.count - 1
while i < fst {
i = i + 1
let ci = reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci)
}
}
return (i, nil)
}
// returns the previous member in the same merge group and the count of members in this group
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = []
for i in range {
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId)
}
}
return (prevMember, memberIds.count)
}
func popChat(_ id: String) {
if let i = getChatIndex(id) {
popChat_(i)
@ -583,13 +623,14 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == groupInfo.id {
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) {
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
withAnimation(.default) {
self.groupMembers[i] = member
self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now
}
return false
} else {
withAnimation { groupMembers.append(member) }
withAnimation { groupMembers.append(GMember(member)) }
return true
}
} else {
@ -598,11 +639,10 @@ final class ChatModel: ObservableObject {
}
func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) {
if let conn = member.activeConn {
var updatedConn = conn
updatedConn.connectionStats = connectionStats
if var conn = member.activeConn {
conn.connectionStats = connectionStats
var updatedMember = member
updatedMember.activeConn = updatedConn
updatedMember.activeConn = conn
_ = upsertGroupMember(groupInfo, updatedMember)
}
}
@ -700,3 +740,19 @@ final class Chat: ObservableObject, Identifiable {
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
final class GMember: ObservableObject, Identifiable {
@Published var wrapped: GroupMember
var created = Date.now
init(_ member: GroupMember) {
self.wrapped = member
}
var id: String { wrapped.id }
var groupId: Int64 { wrapped.groupId }
var groupMemberId: Int64 { wrapped.groupMemberId }
var displayName: String { wrapped.displayName }
var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } }
static let sampleData = GMember(GroupMember.sampleData)
}

View File

@ -502,6 +502,10 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
}
func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSettings: GroupMemberSettings) async throws {
try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings))
}
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
@ -1079,8 +1083,8 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
return []
}
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil }
func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats
.compactMap{ $0.chatInfo.contact }
.filter{ !memberContactIds.contains($0.apiId) }
@ -1362,6 +1366,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatInfo(cInfo)
}
}
case let .groupMemberUpdated(user, groupInfo, _, toMember):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, toMember)
}
}
case let .contactsMerged(user, intoContact, mergedContact):
if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run {
@ -1475,6 +1485,16 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(hostContact.activeConn.id)
}
}
case let .groupLinkConnecting(user, groupInfo, hostMember):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
if let hostConn = hostMember.activeConn {
m.dismissConnReqView(hostConn.id)
m.removeChat(hostConn.id)
}
}
case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
if active(user) {
await MainActor.run {
@ -1534,10 +1554,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateGroup(toGroup)
}
}
case let .memberRole(user, groupInfo, _, _, _, _):
case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _):
if active(user) {
await MainActor.run {
m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
}
}
case let .newMemberContactReceivedInv(user, contact, _, _):

View File

@ -108,7 +108,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try audioSession.setActive(true)
logger.debug("audioSession activated")
} catch {
print(error)
logger.error("failed activating audio session")
}
}
@ -121,7 +120,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try audioSession.setActive(false)
logger.debug("audioSession deactivated")
} catch {
print(error)
logger.error("failed deactivating audio session")
}
suspendOnEndCall()

View File

@ -11,7 +11,7 @@ import SimpleXChat
struct CICallItemView: View {
@EnvironmentObject var m: ChatModel
var chatInfo: ChatInfo
@ObservedObject var chat: Chat
var chatItem: ChatItem
var status: CICallStatus
var duration: Int
@ -60,7 +60,7 @@ struct CICallItemView: View {
@ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chatInfo {
if case let .direct(contact) = chat.chatInfo {
Button {
if let invitation = m.callInvitations[contact.id] {
CallController.shared.answerCall(invitation: invitation)

View File

@ -10,20 +10,92 @@ import SwiftUI
import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature
var icon: String? = nil
var iconColor: Color
var body: some View {
if !revealed, let fs = mergedFeautures() {
HStack {
ForEach(fs, content: featureIconView)
}
.padding(.horizontal, 6)
.padding(.vertical, 6)
} else {
fullFeatureView
}
}
private struct FeatureInfo: Identifiable {
var icon: String
var scale: CGFloat
var color: Color
var param: String?
init(_ f: Feature, _ color: Color, _ param: Int?) {
self.icon = f.iconFilled
self.scale = f.iconScale
self.color = color
self.param = f.hasParam && param != nil ? timeText(param) : nil
}
var id: String {
"\(icon) \(color) \(param ?? "")"
}
}
private func mergedFeautures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = []
var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) {
while i < m.reversedChatItems.count,
let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) {
fs.insert(f, at: 0)
icons.insert(f.icon)
}
i += 1
}
}
return fs.count > 1 ? fs : nil
}
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
switch ci.content {
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
default: nil
}
}
@ViewBuilder private func featureIconView(_ f: FeatureInfo) -> some View {
let i = Image(systemName: f.icon)
.foregroundColor(f.color)
.scaleEffect(f.scale)
if let param = f.param {
HStack {
i
chatEventText(Text(param)).lineLimit(1)
}
} else {
i
}
}
private var fullFeatureView: some View {
HStack(alignment: .bottom, spacing: 4) {
Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor)
.scaleEffect(feature.iconScale)
chatEventText(chatItem)
}
.padding(.leading, 6)
.padding(.bottom, 6)
.padding(.horizontal, 6)
.padding(.vertical, 4)
.textSelection(.disabled)
}
}
@ -31,6 +103,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
}
}

View File

@ -13,11 +13,9 @@ struct CIEventView: View {
var eventText: Text
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
eventText
}
.padding(.leading, 6)
.padding(.bottom, 6)
eventText
.padding(.horizontal, 6)
.padding(.vertical, 4)
.textSelection(.disabled)
}
}

View File

@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct CIFeaturePreferenceView: View {
@EnvironmentObject var chat: Chat
@ObservedObject var chat: Chat
var chatItem: ChatItem
var feature: ChatFeature
var allowed: FeatureAllowed
@ -80,7 +80,6 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider {
quotedItem: nil,
file: nil
)
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
.environmentObject(Chat.sampleData)
CIFeaturePreferenceView(chat: Chat.sampleData, chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
}
}

View File

@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct CIFileView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
let file: CIFile?
let edited: Bool
@ -83,7 +84,7 @@ struct CIFileView: View {
if fileSizeValid() {
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = ChatModel.shared.currentUser {
if let user = m.currentUser {
let encrypted = privacyEncryptLocalFilesGroupDefault.get()
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
}
@ -234,18 +235,17 @@ struct CIFileView_Previews: PreviewProvider {
file: nil
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}

View File

@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem
let image: String
@ -36,7 +37,7 @@ struct CIImageView: View {
switch file.fileStatus {
case .rcvInvitation:
Task {
if let user = ChatModel.shared.currentUser {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
}
}

View File

@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct CIMemberCreatedContactView: View {
@EnvironmentObject var m: ChatModel
var chatItem: ChatItem
var body: some View {
@ -21,7 +22,7 @@ struct CIMemberCreatedContactView: View {
.onTapGesture {
dismissAllSheets(animated: true)
DispatchQueue.main.async {
ChatModel.shared.chatId = "@\(contactId)"
m.chatId = "@\(contactId)"
}
}
} else {

View File

@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct CIMetaView: View {
@EnvironmentObject var chat: Chat
@ObservedObject var chat: Chat
var chatItem: ChatItem
var metaColor = Color.secondary
var paleMetaColor = Color(UIColor.tertiaryLabel)
@ -95,15 +95,14 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
struct CIMetaView_Previews: PreviewProvider {
static var previews: some View {
Group {
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chatItem: ChatItem.getDeletedContentSample())
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
}
.previewLayout(.fixed(width: 360, height: 100))
.environmentObject(Chat.sampleData)
}
}

View File

@ -12,7 +12,8 @@ import SimpleXChat
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
struct CIRcvDecryptionError: View {
@EnvironmentObject var chat: Chat
@EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
var msgDecryptError: MsgDecryptError
var msgCount: UInt32
var chatItem: ChatItem
@ -45,7 +46,7 @@ struct CIRcvDecryptionError: View {
do {
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats {
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s)
m.updateGroupMemberConnectionStats(groupInfo, member, s)
}
} catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))")
@ -79,8 +80,8 @@ struct CIRcvDecryptionError: View {
}
} else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir,
let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }),
let memberStats = modelMember.activeConn?.connectionStats {
let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
@ -122,7 +123,7 @@ struct CIRcvDecryptionError: View {
)
}
.padding(.horizontal, 12)
CIMetaView(chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.onTapGesture(perform: { onClick() })
@ -142,7 +143,7 @@ struct CIRcvDecryptionError: View {
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
}
.padding(.horizontal, 12)
CIMetaView(chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.onTapGesture(perform: { onClick() })
@ -173,7 +174,7 @@ struct CIRcvDecryptionError: View {
do {
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false)
await MainActor.run {
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats)
m.updateGroupMemberConnectionStats(groupInfo, mem, stats)
}
} catch let error {
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
@ -190,7 +191,7 @@ struct CIRcvDecryptionError: View {
do {
let stats = try apiSyncContactRatchet(contact.apiId, false)
await MainActor.run {
ChatModel.shared.updateContactConnectionStats(contact, stats)
m.updateContactConnectionStats(contact, stats)
}
} catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")

View File

@ -11,6 +11,7 @@ import AVKit
import SimpleXChat
struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem
private let image: String
@ -101,7 +102,7 @@ struct CIVideoView: View {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in
.onChange(of: m.stopPreviousRecPlay) { playingUrl in
if playingUrl != url {
player.pause()
videoPlaying = false
@ -124,7 +125,7 @@ struct CIVideoView: View {
}
if !videoPlaying {
Button {
ChatModel.shared.stopPreviousRecPlay = url
m.stopPreviousRecPlay = url
player.play()
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
@ -256,7 +257,7 @@ struct CIVideoView: View {
// TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task {
if let user = ChatModel.shared.currentUser {
if let user = m.currentUser {
await receiveFile(user, file.fileId, encrypted, false)
}
}
@ -290,7 +291,7 @@ struct CIVideoView: View {
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) {
ChatModel.shared.stopPreviousRecPlay = url
m.stopPreviousRecPlay = url
if let player = fullPlayer {
player.play()
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in

View File

@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct CIVoiceView: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@ -91,7 +92,7 @@ struct CIVoiceView: View {
}
private func metaView() -> some View {
CIMetaView(chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem)
}
}
@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View {
private func downloadButton(_ recordingFile: CIFile) -> some View {
Button {
Task {
if let user = ChatModel.shared.currentUser {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
}
}
@ -284,6 +285,7 @@ struct CIVoiceView_Previews: PreviewProvider {
)
Group {
CIVoiceView(
chat: Chat.sampleData,
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
@ -292,12 +294,11 @@ struct CIVoiceView_Previews: PreviewProvider {
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}

View File

@ -11,6 +11,7 @@ import SimpleXChat
struct DeletedItemView: View {
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
var body: some View {
@ -18,7 +19,7 @@ struct DeletedItemView: View {
Text(chatItem.content.text)
.foregroundColor(.secondary)
.italic()
CIMetaView(chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
@ -32,8 +33,8 @@ struct DeletedItemView: View {
struct DeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
DeletedItemView(chatItem: ChatItem.getDeletedContentSample())
DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
}
.previewLayout(.fixed(width: 360, height: 200))
}

View File

@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct EmojiItemView: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem
var body: some View {
@ -17,7 +18,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text)
.padding(.top, 8)
.padding(.horizontal, 6)
CIMetaView(chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.bottom, 8)
.padding(.horizontal, 12)
}
@ -32,8 +33,8 @@ func emojiText(_ text: String) -> Text {
struct EmojiItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
}
.previewLayout(.fixed(width: 360, height: 70))
}

View File

@ -88,13 +88,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete)
)
Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
}
.previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
}
}

File diff suppressed because one or more lines are too long

View File

@ -150,7 +150,7 @@ struct FullScreenMediaView: View {
private func startPlayerAndNotify() {
if let player = player {
ChatModel.shared.stopPreviousRecPlay = url
m.stopPreviousRecPlay = url
player.play()
}
}

View File

@ -10,11 +10,12 @@ import SwiftUI
import SimpleXChat
struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat
var msgError: MsgErrorType
var chatItem: ChatItem
var body: some View {
CIMsgError(chatItem: chatItem) {
CIMsgError(chat: chat, chatItem: chatItem) {
switch msgError {
case .msgSkipped:
AlertManager.shared.showAlertMsg(
@ -52,6 +53,7 @@ struct IntegrityErrorItemView: View {
}
struct CIMsgError: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem
var onTap: () -> Void
@ -60,7 +62,7 @@ struct CIMsgError: View {
Text(chatItem.content.text)
.foregroundColor(.red)
.italic()
CIMetaView(chatItem: chatItem)
CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
@ -74,6 +76,6 @@ struct CIMsgError: View {
struct IntegrityErrorItemView_Previews: PreviewProvider {
static var previews: some View {
IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
}
}

View File

@ -10,39 +10,70 @@ import SwiftUI
import SimpleXChat
struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
@Binding var revealed: Bool
var body: some View {
HStack(alignment: .bottom, spacing: 0) {
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted {
markedDeletedText("moderated by \(byGroupMember.chatViewName)")
} else {
markedDeletedText("marked deleted")
}
CIMetaView(chatItem: chatItem)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
(Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
.textSelection(.disabled)
}
func markedDeletedText(_ s: LocalizedStringKey) -> some View {
Text(s)
.font(.caption)
.foregroundColor(.secondary)
.italic()
.lineLimit(1)
var mergedMarkedDeletedText: LocalizedStringKey {
if !revealed,
let ciCategory = chatItem.mergeCategory,
var i = m.getChatItemIndex(chatItem) {
var moderated = 0
var blocked = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < m.reversedChatItems.count,
let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
case let .moderated(_, byGroupMember):
moderated += 1
moderatedBy.insert(byGroupMember.displayName)
case .blocked: blocked += 1
case .deleted: deleted += 1
}
i += 1
}
let total = moderated + blocked + deleted
return total <= 1
? markedDeletedText
: total == moderated
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
: total == blocked
? "\(total) messages blocked"
: "\(total) messages marked deleted"
} else {
return markedDeletedText
}
}
var markedDeletedText: LocalizedStringKey {
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked"
default: "marked deleted"
}
}
}
struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View {
Group {
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)))
MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 200))
}

View File

@ -25,7 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
}
struct MsgContentView: View {
@EnvironmentObject var chat: Chat
@ObservedObject var chat: Chat
var text: String
var formattedText: [FormattedText]? = nil
var sender: String? = nil
@ -152,6 +152,7 @@ struct MsgContentView_Previews: PreviewProvider {
static var previews: some View {
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return MsgContentView(
chat: Chat.sampleData,
text: chatItem.text,
formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName,

View File

@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat
struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo?
@ -290,8 +291,8 @@ struct ChatItemInfoView: View {
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
memberDeliveryStatuses.compactMap({ mds in
if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) {
return (mem, mds.memberDeliveryStatus)
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem.wrapped, mds.memberDeliveryStatus)
} else {
return nil
}

View File

@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat
struct ChatItemView: View {
var chatInfo: ChatInfo
@ObservedObject var chat: Chat
var chatItem: ChatItem
var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil
@ -19,8 +19,19 @@ struct ChatItemView: View {
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) {
self.chatInfo = chatInfo
init(
chat: Chat,
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
scrollProxy: ScrollViewProxy? = nil,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) {
self.chat = chat
self.chatItem = chatItem
self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy)
@ -33,15 +44,15 @@ struct ChatItemView: View {
var body: some View {
let ci = chatItem
if chatItem.meta.itemDeleted != nil && !revealed {
MarkedDeletedItemView(chatItem: chatItem)
if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chatItem: ci)
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
framedItemView()
}
@ -51,14 +62,15 @@ struct ChatItemView: View {
}
private func framedItemView() -> some View {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
}
}
struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel
var chatInfo: ChatInfo
@ObservedObject var chat: Chat
var chatItem: ChatItem
@Binding var revealed: Bool
var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@ -72,15 +84,14 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvCall(status, duration): callItemView(status, duration)
case let .rcvIntegrityError(msgError):
if developerTools {
IntegrityErrorItemView(msgError: msgError, chatItem: chatItem)
IntegrityErrorItemView(chat: chat, msgError: msgError, chatItem: chatItem)
} else {
ZStack {}
}
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case .rcvDirectEvent: eventItemView()
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
case .rcvGroupEvent: eventItemView()
case .sndGroupEvent: eventItemView()
@ -89,9 +100,9 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param)
CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary)
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@ -103,15 +114,15 @@ struct ChatItemContentView<Content: View>: View {
}
private func deletedItemView() -> some View {
DeletedItemView(chatItem: chatItem)
DeletedItemView(chat: chat, chatItem: chatItem)
}
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration)
CICallItemView(chat: chat, chatItem: chatItem, status: status, duration: duration)
}
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito)
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
}
private func eventItemView() -> some View {
@ -119,7 +130,9 @@ struct ChatItemContentView<Content: View>: View {
}
private func eventItemViewText() -> Text {
if let member = chatItem.memberDisplayName {
if !revealed, let t = mergedGroupEventText {
return chatEventText(t + Text(" ") + chatItem.timestampText)
} else if let member = chatItem.memberDisplayName {
return Text(member + " ")
.font(.caption)
.foregroundColor(.secondary)
@ -131,36 +144,44 @@ struct ChatItemContentView<Content: View>: View {
}
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor)
CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
}
private var membersConnectedItemText: Text {
if let t = membersConnectedText {
return chatEventText(t, chatItem.timestampText)
private var mergedGroupEventText: Text? {
let (count, ns) = chatModel.getConnectedMemberNames(chatItem)
let members: LocalizedStringKey =
switch ns.count {
case 1: "\(ns[0]) connected"
case 2: "\(ns[0]) and \(ns[1]) connected"
case 3: "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
default:
ns.count > 3
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
: ""
}
return if count <= 1 {
nil
} else if ns.count == 0 {
Text("\(count) group events")
} else if count > ns.count {
Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
} else {
return eventItemViewText()
Text(members)
}
}
private var membersConnectedText: LocalizedStringKey? {
let ns = chatModel.getConnectedMemberNames(chatItem)
return ns.count > 3
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
: ns.count == 3
? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
: ns.count == 2
? "\(ns[0]) and \(ns[1]) connected"
: nil
}
}
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
(Text(eventText) + Text(" ") + ts)
func chatEventText(_ text: Text) -> Text {
text
.font(.caption)
.foregroundColor(.secondary)
.fontWeight(.light)
}
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
chatEventText(Text(eventText) + Text(" ") + ts)
}
func chatEventText(_ ci: ChatItem) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText)
}
@ -168,15 +189,15 @@ func chatEventText(_ ci: ChatItem) -> Text {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
}
.previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData)
@ -188,7 +209,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chat: Chat.sampleData,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@ -199,7 +220,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chat: Chat.sampleData,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
@ -210,7 +231,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chat: Chat.sampleData,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@ -221,7 +242,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chat: Chat.sampleData,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@ -232,7 +253,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true)
)
ChatItemView(
chatInfo: ChatInfo.sampleData.direct,
chat: Chat.sampleData,
chatItem: ChatItem(
chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),

View File

@ -21,9 +21,7 @@ struct ChatView: View {
@State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState()
@State private var deletingItem: ChatItem? = nil
@State private var keyboardVisible = false
@State private var showDeleteMessage = false
@State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile?
@State private var connectionCode: String?
@ -36,7 +34,8 @@ struct ChatView: View {
@State private var searchText: String = ""
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GroupMember? = nil
@State private var membersLoaded = false
@State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@ -97,6 +96,8 @@ struct ChatView: View {
if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:]
chatModel.reversedChatItems = []
chatModel.groupMembers = []
membersLoaded = false
}
}
}
@ -134,18 +135,21 @@ struct ChatView: View {
}
} else if case let .group(groupInfo) = cInfo {
Button {
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showChatInfoSheet = true
}
}
Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
} label: {
ChatInfoToolbar(chat: chat)
}
.appSheet(isPresented: $showChatInfoSheet) {
GroupChatInfoView(chat: chat, groupInfo: groupInfo)
GroupChatInfoView(
chat: chat,
groupInfo: Binding(
get: { groupInfo },
set: { gInfo in
chat.chatInfo = .group(groupInfo: gInfo)
chat.created = Date.now
}
)
)
}
}
}
@ -208,6 +212,17 @@ struct ChatView: View {
}
}
private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatModel.chatId == groupInfo.id {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
membersLoaded = true
updateView()
}
}
}
private func initChatView() {
let cInfo = chat.chatInfo
if case let .direct(contact) = cInfo {
@ -416,13 +431,7 @@ struct ChatView: View {
private func addMembersButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
Task {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showAddMembersSheet = true
}
}
Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } }
}
} label: {
Image(systemName: "person.crop.circle.badge.plus")
@ -477,73 +486,30 @@ struct ChatView: View {
}
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci)
if ci.memberConnected != nil && nextItem?.memberConnected != nil {
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
ZStack {} // scroll doesn't work if it's EmptyView()
} else {
if prevItem == nil || showMemberImage(member, prevItem) {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
Text(member.displayName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, memberImageSize + 14)
.padding(.top, 7)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture { selectedMember = member }
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
}
chatItemWithMenu(ci, maxWidth)
}
}
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, maxWidth)
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
}
} else {
chatItemWithMenu(ci, maxWidth)
.padding(.horizontal)
.padding(.top, 5)
}
}
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
ChatItemWithMenu(
ci: ci,
chat: chat,
chatItem: ci,
maxWidth: maxWidth,
scrollProxy: scrollProxy,
deleteMessage: deleteMessage,
deletingItem: $deletingItem,
composeState: $composeState,
showDeleteMessage: $showDeleteMessage
selectedMember: $selectedMember,
chatView: self
)
.environmentObject(chat)
}
private struct ChatItemWithMenu: View {
@EnvironmentObject var chat: Chat
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme
var ci: ChatItem
@ObservedObject var chat: Chat
var chatItem: ChatItem
var maxWidth: CGFloat
var scrollProxy: ScrollViewProxy?
var deleteMessage: (CIDeleteMode) -> Void
@Binding var deletingItem: ChatItem?
@Binding var composeState: ComposeState
@Binding var showDeleteMessage: Bool
@Binding var selectedMember: GMember?
var chatView: ChatView
@State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false
@State private var deletingItems: [Int64] = []
@State private var showDeleteMessages = false
@State private var revealed = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
@ -555,18 +521,114 @@ struct ChatView: View {
@State private var playbackTime: TimeInterval?
var body: some View {
let (currIndex, nextItem) = m.getNextChatItem(chatItem)
let ciCategory = chatItem.mergeCategory
if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) {
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
ZStack {} // scroll doesn't work if it's EmptyView()
} else {
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden)
if revealed, let range = range {
let items = Array(zip(Array(range), m.reversedChatItems[range]))
ForEach(items, id: \.1.viewId) { (i, ci) in
let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1]
chatItemView(ci, nil, prev)
}
} else {
chatItemView(chatItem, range, prevItem)
}
}
}
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) =
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
Text(memberNames(member, prevMember, memCount))
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, memberImageSize + 14)
.padding(.top, 7)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture {
if chatView.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await chatView.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
}
}
}
}
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
}
}
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
.padding(.top, 5)
}
}
private func memberNames(_ member: GroupMember, _ prevMember: GroupMember?, _ memCount: Int) -> LocalizedStringKey {
let name = member.displayName
return if let prevName = prevMember?.displayName {
memCount > 2
? "\(name), \(prevName) and \(memCount - 2) members"
: "\(name) and \(prevName)"
} else {
"\(name)"
}
}
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
let uiMenu: Binding<UIMenu> = Binding(
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) },
get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) },
set: { _ in }
)
VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
.accessibilityLabel("")
ChatItemView(
chat: chat,
chatItem: ci,
maxWidth: maxWidth,
scrollProxy: chatView.scrollProxy,
revealed: $revealed,
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
chatItemReactions()
chatItemReactions(ci)
.padding(.bottom, 4)
}
}
@ -580,6 +642,11 @@ struct ChatView: View {
}
}
}
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessages()
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.onDisappear {
@ -597,7 +664,15 @@ struct ChatView: View {
}
}
private func chatItemReactions() -> some View {
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
switch (prevItem?.chatDir) {
case .groupSnd: return true
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
default: return false
}
}
private func chatItemReactions(_ ci: ChatItem) -> some View {
HStack(spacing: 4) {
ForEach(ci.reactions, id: \.reaction) { r in
let v = HStack(spacing: 4) {
@ -617,7 +692,7 @@ struct ChatView: View {
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
v.onTapGesture {
setReaction(add: !r.userReacted, reaction: r.reaction)
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
}
} else {
v
@ -626,10 +701,10 @@ struct ChatView: View {
}
}
private func menu(live: Bool) -> [UIMenuElement] {
private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> [UIMenuElement] {
var menu: [UIMenuElement] = []
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
let rs = allReactions()
let rs = allReactions(ci)
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
rs.count > 0 {
var rm: UIMenu
@ -646,10 +721,10 @@ struct ChatView: View {
menu.append(rm)
}
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
menu.append(replyUIAction())
menu.append(replyUIAction(ci))
}
menu.append(shareUIAction())
menu.append(copyUIAction())
menu.append(shareUIAction(ci))
menu.append(copyUIAction(ci))
if let fileSource = getLoadedFileSource(ci.file) {
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil {
@ -662,9 +737,9 @@ struct ChatView: View {
}
}
if ci.meta.editable && !mc.isVoice && !live {
menu.append(editAction())
menu.append(editAction(ci))
}
menu.append(viewInfoUIAction())
menu.append(viewInfoUIAction(ci))
if revealed {
menu.append(hideUIAction())
}
@ -674,25 +749,31 @@ struct ChatView: View {
menu.append(cancelFileUIAction(file.fileId, cancelAction))
}
if !live || !ci.meta.isLive {
menu.append(deleteUIAction())
menu.append(deleteUIAction(ci))
}
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
menu.append(moderateUIAction(groupInfo))
menu.append(moderateUIAction(ci, groupInfo))
}
} else if ci.meta.itemDeleted != nil {
if !ci.isDeletedContent {
if revealed {
menu.append(hideUIAction())
} else if !ci.isDeletedContent {
menu.append(revealUIAction())
} else if range != nil {
menu.append(expandUIAction())
}
menu.append(viewInfoUIAction())
menu.append(deleteUIAction())
menu.append(viewInfoUIAction(ci))
menu.append(deleteUIAction(ci))
} else if ci.isDeletedContent {
menu.append(viewInfoUIAction())
menu.append(deleteUIAction())
menu.append(viewInfoUIAction(ci))
menu.append(deleteUIAction(ci))
} else if ci.mergeCategory != nil {
menu.append(revealed ? shrinkUIAction() : expandUIAction())
}
return menu
}
private func replyUIAction() -> UIAction {
private func replyUIAction(_ ci: ChatItem) -> UIAction {
UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left")
@ -727,11 +808,11 @@ struct ChatView: View {
)
}
private func allReactions() -> [UIAction] {
private func allReactions(_ ci: ChatItem) -> [UIAction] {
MsgReaction.values.compactMap { r in
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
? nil
: UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) }
: UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) }
}
}
@ -739,7 +820,7 @@ struct ChatView: View {
rs.count > 4 ? 3 : 4
}
private func setReaction(add: Bool, reaction: MsgReaction) {
private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) {
Task {
do {
let cInfo = chat.chatInfo
@ -751,7 +832,7 @@ struct ChatView: View {
reaction: reaction
)
await MainActor.run {
ChatModel.shared.updateChatItem(chat.chatInfo, chatItem)
m.updateChatItem(chat.chatInfo, chatItem)
}
} catch let error {
logger.error("apiChatItemReaction error: \(responseError(error))")
@ -759,7 +840,7 @@ struct ChatView: View {
}
}
private func shareUIAction() -> UIAction {
private func shareUIAction(_ ci: ChatItem) -> UIAction {
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up")
@ -772,7 +853,7 @@ struct ChatView: View {
}
}
private func copyUIAction() -> UIAction {
private func copyUIAction(_ ci: ChatItem) -> UIAction {
UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc")
@ -805,7 +886,7 @@ struct ChatView: View {
}
}
private func editAction() -> UIAction {
private func editAction(_ ci: ChatItem) -> UIAction {
UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"),
image: UIImage(systemName: "square.and.pencil")
@ -816,7 +897,7 @@ struct ChatView: View {
}
}
private func viewInfoUIAction() -> UIAction {
private func viewInfoUIAction(_ ci: ChatItem) -> UIAction {
UIAction(
title: NSLocalizedString("Info", comment: "chat item action"),
image: UIImage(systemName: "info.circle")
@ -829,10 +910,7 @@ struct ChatView: View {
chatItemInfo = ciInfo
}
if case let .group(gInfo) = chat.chatInfo {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
}
await chatView.loadGroupMembers(gInfo)
}
} catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))")
@ -853,7 +931,7 @@ struct ChatView: View {
message: Text(cancelAction.alert.message),
primaryButton: .destructive(Text(cancelAction.alert.confirm)) {
Task {
if let user = ChatModel.shared.currentUser {
if let user = m.currentUser {
await cancelFile(user: user, fileId: fileId)
}
}
@ -874,18 +952,45 @@ struct ChatView: View {
}
}
private func deleteUIAction() -> UIAction {
private func deleteUIAction(_ ci: ChatItem) -> UIAction {
UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"),
attributes: [.destructive]
) { _ in
showDeleteMessage = true
deletingItem = ci
if !revealed && ci.meta.itemDeleted != nil,
let currIndex = m.getChatItemIndex(ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
var itemIds: [Int64] = []
for i in range {
itemIds.append(m.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
} else {
showDeleteMessage = true
deletingItem = ci
}
} else {
showDeleteMessage = true
deletingItem = ci
}
}
}
private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction {
private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange<Int>? {
if let currIndex = currIndex,
let prevHidden = prevHidden,
prevHidden > currIndex {
currIndex...prevHidden
} else {
nil
}
}
private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction {
UIAction(
title: NSLocalizedString("Moderate", comment: "chat item action"),
image: UIImage(systemName: "flag"),
@ -917,20 +1022,105 @@ struct ChatView: View {
}
}
}
private func expandUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Expand", comment: "chat item action"),
image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down")
) { _ in
withAnimation {
revealed = true
}
}
}
private func shrinkUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Hide", comment: "chat item action"),
image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up")
) { _ in
withAnimation {
revealed = false
}
}
}
private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
}
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
switch (prevItem?.chatDir) {
case .groupSnd: return true
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
default: return false
var deleteMessagesTitle: LocalizedStringKey {
let n = deletingItems.count
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
}
private func deleteMessages() {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
var deletedItems: [ChatItem] = []
for itemId in itemIds {
do {
let (di, _) = try await apiDeleteChatItem(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemId: itemId,
mode: .cidmInternal
)
deletedItems.append(di)
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
await MainActor.run {
for di in deletedItems {
m.removeChatItem(chatInfo, di)
}
}
}
}
}
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
var deletedItem: ChatItem
var toItem: ChatItem?
if case .cidmBroadcast = mode,
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
groupId: groupInfo.apiId,
groupMemberId: groupMember.groupMemberId,
itemId: di.id
)
} else {
(deletedItem, toItem) = try await apiDeleteChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: di.id,
mode: mode
)
}
DispatchQueue.main.async {
deletingItem = nil
if let toItem = toItem {
_ = m.upsertChatItem(chat.chatInfo, toItem)
} else {
m.removeChatItem(chat.chatInfo, deletedItem)
}
}
}
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
}
}
private func scrollToBottom(_ proxy: ScrollViewProxy) {
if let ci = chatModel.reversedChatItems.first {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
@ -942,44 +1132,6 @@ struct ChatView: View {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
}
}
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
var deletedItem: ChatItem
var toItem: ChatItem?
if case .cidmBroadcast = mode,
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
groupId: groupInfo.apiId,
groupMemberId: groupMember.groupMemberId,
itemId: di.id
)
} else {
(deletedItem, toItem) = try await apiDeleteChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: di.id,
mode: mode
)
}
DispatchQueue.main.async {
deletingItem = nil
if let toItem = toItem {
_ = chatModel.upsertChatItem(chat.chatInfo, toItem)
} else {
chatModel.removeChatItem(chat.chatInfo, deletedItem)
}
}
}
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
}
}
@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {

View File

@ -592,12 +592,14 @@ struct ComposeView: View {
EmptyView()
case let .quotedItem(chatItem: quotedItem):
ContextItemView(
chat: chat,
contextItem: quotedItem,
contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
)
case let .editingItem(chatItem: editingItem):
ContextItemView(
chat: chat,
contextItem: editingItem,
contextIcon: "pencil",
cancelContextItem: { clearState() }

View File

@ -11,6 +11,7 @@ import SimpleXChat
struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
let contextItem: ChatItem
let contextIcon: String
let cancelContextItem: () -> Void
@ -48,6 +49,7 @@ struct ContextItemView: View {
private func msgContentView(lines: Int) -> some View {
MsgContentView(
chat: chat,
text: contextItem.text,
formattedText: contextItem.formattedText
)
@ -59,6 +61,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider {
static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
}
}

View File

@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View {
do {
for contactId in selectedContacts {
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) }
await MainActor.run { _ = chatModel.upsertGroupMember(groupInfo, member) }
}
addedMembersCb(selectedContacts)
} catch {

View File

@ -15,7 +15,7 @@ struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat
@State var groupInfo: GroupInfo
@Binding var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String?
@ -35,14 +35,30 @@ struct GroupChatInfoView: View {
case leaveGroupAlert
case cantInviteIncognitoAlert
case largeGroupReceiptsDisabled
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: GroupChatInfoViewAlert { get { self } }
var id: String {
switch self {
case .deleteGroupAlert: return "deleteGroupAlert"
case .clearChatAlert: return "clearChatAlert"
case .leaveGroupAlert: return "leaveGroupAlert"
case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert"
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .error(title, _): return "error \(title)"
}
}
}
var body: some View {
NavigationView {
let members = chatModel.groupMembers
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved }
.filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
List {
@ -57,7 +73,7 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage()
}
groupPreferencesButton($groupInfo)
if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption()
} else {
sendReceiptsOptionDisabled()
@ -84,17 +100,17 @@ struct GroupChatInfoView: View {
.padding(.leading, 8)
}
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) }
memberView(groupInfo.membership, user: true)
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.groupMemberId)
memberInfoView(member)
} label: {
EmptyView()
}
.opacity(0)
memberView(member)
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
}
}
}
@ -126,6 +142,10 @@ struct GroupChatInfoView: View {
case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
}
}
.onAppear {
@ -174,7 +194,7 @@ struct GroupChatInfoView: View {
Task {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
}
}
}
@ -183,44 +203,79 @@ struct GroupChatInfoView: View {
}
}
private func memberView(_ member: GroupMember, user: Bool = false) -> some View {
HStack{
ProfileImage(imageStr: member.image)
.frame(width: 38, height: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
private struct MemberRowView: View {
var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
var user: Bool = false
@Binding var alert: GroupChatInfoViewAlert?
var body: some View {
let member = groupMember.wrapped
let v = HStack{
ProfileImage(imageStr: member.image)
.frame(width: 38, height: 38)
.padding(.trailing, 2)
// TODO server connection status
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
let role = member.memberRole
if role == .owner || role == .admin {
Text(member.memberRole.text)
.foregroundColor(.secondary)
}
}
Spacer()
let role = member.memberRole
if role == .owner || role == .admin {
Text(member.memberRole.text)
.foregroundColor(.secondary)
if user {
v
} else if member.canBeRemoved(groupInfo: groupInfo) {
removeSwipe(member, blockSwipe(member, v))
} else {
blockSwipe(member, v)
}
}
private func blockSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .leading) {
if member.memberSettings.showMessages {
Button {
alert = .blockMemberAlert(mem: member)
} label: {
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
}
} else {
Button {
alert = .unblockMemberAlert(mem: member)
} label: {
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
}
}
}
}
private func removeSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .trailing) {
Button(role: .destructive) {
alert = .removeMemberAlert(mem: member)
} label: {
Label("Remove member", systemImage: "trash")
.foregroundColor(Color.red)
}
}
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
@ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View {
if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) {
GroupMemberInfoView(groupInfo: groupInfo, member: member)
.navigationBarHidden(false)
}
private func memberInfoView(_ groupMember: GMember) -> some View {
GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
.navigationBarHidden(false)
}
private func groupLinkButton() -> some View {
@ -381,6 +436,28 @@ struct GroupChatInfoView: View {
alert = .largeGroupReceiptsDisabled
}
}
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Remove member?"),
message: Text("Member will be removed from group - this cannot be undone!"),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
} catch let error {
logger.error("apiRemoveMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error removing member")
alert = .error(title: a.title, error: a.message)
}
}
},
secondaryButton: .cancel()
)
}
}
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
@ -402,6 +479,14 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
}
}
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
func cantInviteIncognitoAlert() -> Alert {
Alert(
title: Text("Can't invite contacts!"),
@ -418,6 +503,9 @@ func largeGroupReceiptsDisabledAlert() -> Alert {
struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData)
GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData)
)
}
}

View File

@ -12,8 +12,8 @@ import SimpleXChat
struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
var groupInfo: GroupInfo
@State var member: GroupMember
@State var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember
var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil
@ -25,6 +25,8 @@ struct GroupMemberInfoView: View {
@State private var progressIndicator = false
enum GroupMemberInfoViewAlert: Identifiable {
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
case switchAddressAlert
@ -35,8 +37,10 @@ struct GroupMemberInfoView: View {
var id: String {
switch self {
case .removeMemberAlert: return "removeMemberAlert"
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)"
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert"
@ -66,6 +70,7 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View {
ZStack {
VStack {
let member = groupMember.wrapped
List {
groupMemberInfoHeader(member)
.listRowBackground(Color.clear)
@ -159,9 +164,14 @@ struct GroupMemberInfoView: View {
}
}
if member.canBeRemoved(groupInfo: groupInfo) {
Section {
removeMemberButton(member)
Section {
if member.memberSettings.showMessages {
blockMemberButton(member)
} else {
unblockMemberButton(member)
}
if member.canBeRemoved(groupInfo: groupInfo) {
removeMemberButton(member)
}
}
@ -182,7 +192,7 @@ struct GroupMemberInfoView: View {
do {
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
member = mem
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
connectionCode = code
} catch let error {
@ -190,15 +200,20 @@ struct GroupMemberInfoView: View {
}
justOpened = false
}
.onChange(of: newRole) { _ in
.onChange(of: newRole) { newRole in
if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole)
}
}
.onChange(of: member.memberRole) { role in
newRole = role
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in
switch(alertItem) {
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
@ -263,7 +278,7 @@ struct GroupMemberInfoView: View {
progressIndicator = true
Task {
do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId)
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run {
progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
@ -321,20 +336,20 @@ struct GroupMemberInfoView: View {
}
private func verifyCodeButton(_ code: String) -> some View {
NavigationLink {
let member = groupMember.wrapped
return NavigationLink {
VerifyCodeView(
displayName: member.displayName,
connectionCode: code,
connectionVerified: member.verified,
verify: { code in
var member = groupMember.wrapped
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
let (verified, existingCode) = r
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode
member.activeConn?.connectionCode = connCode
if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
chatModel.groupMembers[i].activeConn?.connectionCode = connCode
}
_ = chatModel.upsertGroupMember(groupInfo, member)
return r
}
return nil
@ -368,12 +383,29 @@ struct GroupMemberInfoView: View {
}
}
private func blockMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
alert = .blockMemberAlert(mem: mem)
} label: {
Label("Block member", systemImage: "hand.raised")
.foregroundColor(.red)
}
}
private func unblockMemberButton(_ mem: GroupMember) -> some View {
Button {
alert = .unblockMemberAlert(mem: mem)
} label: {
Label("Unblock member", systemImage: "hand.raised.slash")
}
}
private func removeMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
alert = .removeMemberAlert(mem: mem)
} label: {
Label("Remove member", systemImage: "trash")
.foregroundColor(Color.red)
.foregroundColor(.red)
}
}
@ -409,7 +441,6 @@ struct GroupMemberInfoView: View {
do {
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
await MainActor.run {
member = updatedMember
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
@ -430,10 +461,10 @@ struct GroupMemberInfoView: View {
private func switchMemberAddress() {
Task {
do {
let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
connectionStats = stats
await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
dismiss()
}
} catch let error {
@ -449,10 +480,10 @@ struct GroupMemberInfoView: View {
private func abortSwitchMemberAddress() {
Task {
do {
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId)
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
connectionStats = stats
await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats)
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
}
} catch let error {
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
@ -467,7 +498,7 @@ struct GroupMemberInfoView: View {
private func syncMemberConnection(force: Bool) {
Task {
do {
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force)
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force)
connectionStats = stats
await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats)
@ -484,11 +515,54 @@ struct GroupMemberInfoView: View {
}
}
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Block member?"),
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
primaryButton: .destructive(Text("Block")) {
toggleShowMemberMessages(gInfo, mem, false)
},
secondaryButton: .cancel()
)
}
func unblockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Unblock member?"),
message: Text("Messages from \(mem.chatViewName) will be shown!"),
primaryButton: .default(Text("Unblock")) {
toggleShowMemberMessages(gInfo, mem, true)
},
secondaryButton: .cancel()
)
}
func toggleShowMemberMessages(_ gInfo: GroupInfo, _ member: GroupMember, _ showMessages: Bool) {
var memberSettings = member.memberSettings
memberSettings.showMessages = showMessages
updateMemberSettings(gInfo, member, memberSettings)
}
func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSettings: GroupMemberSettings) {
Task {
do {
try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings)
await MainActor.run {
var mem = member
mem.memberSettings = memberSettings
_ = ChatModel.shared.upsertGroupMember(gInfo, mem)
}
} catch let error {
logger.error("apiSetMemberSettings error \(responseError(error))")
}
}
}
struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View {
GroupMemberInfoView(
groupInfo: GroupInfo.sampleData,
member: GroupMember.sampleData
groupMember: GMember.sampleData
)
}
}

View File

@ -29,7 +29,7 @@ struct ChatListView: View {
ZStack(alignment: .topLeading) {
NavStackCompat(
isActive: Binding(
get: { ChatModel.shared.chatId != nil },
get: { chatModel.chatId != nil },
set: { _ in }
),
destination: chatView

View File

@ -119,7 +119,7 @@ struct ContactConnectionInfo: View {
if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) {
await MainActor.run {
contactConnection = conn
ChatModel.shared.updateContactConnection(conn)
m.updateContactConnection(conn)
dismiss()
}
}

View File

@ -52,7 +52,6 @@ struct VideoPlayerView: UIViewRepresentable {
var timeObserver: Any? = nil
deinit {
print("deinit coordinator of VideoPlayer")
if let timeObserver = timeObserver {
NotificationCenter.default.removeObserver(timeObserver)
}

View File

@ -48,7 +48,7 @@ struct AddContactView: View {
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run {
contactConnection = conn
ChatModel.shared.updateContactConnection(conn)
chatModel.updateContactConnection(conn)
}
}
} catch {

View File

@ -189,7 +189,7 @@ struct AddGroupView: View {
Task {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
m.groupMembers = groupMembers.map { GMember.init($0) }
}
}
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])

View File

@ -119,7 +119,7 @@ struct PrivacySettings: View {
Text("Send delivery receipts to")
} footer: {
VStack(alignment: .leading) {
Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.")
Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.")
Text("They can be overridden in contact and group settings.")
}
.frame(maxWidth: .infinity, alignment: .leading)

View File

@ -73,6 +73,7 @@ public enum ChatCommand {
case apiGetNetworkConfig
case reconnectAllServers
case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings)
case apiContactInfo(contactId: Int64)
case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
case apiSwitchContact(contactId: Int64)
@ -198,6 +199,7 @@ public enum ChatCommand {
case .apiGetNetworkConfig: return "/network"
case .reconnectAllServers: return "/reconnect"
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))"
case let .apiContactInfo(contactId): return "/_info @\(contactId)"
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
case let .apiSwitchContact(contactId): return "/_switch @\(contactId)"
@ -325,6 +327,7 @@ public enum ChatCommand {
case .apiGetNetworkConfig: return "apiGetNetworkConfig"
case .reconnectAllServers: return "reconnectAllServers"
case .apiSetChatSettings: return "apiSetChatSettings"
case .apiSetMemberSettings: return "apiSetMemberSettings"
case .apiContactInfo: return "apiContactInfo"
case .apiGroupMemberInfo: return "apiGroupMemberInfo"
case .apiSwitchContact: return "apiSwitchContact"
@ -496,6 +499,7 @@ public enum ChatResponse: Decodable, Error {
case acceptingContactRequest(user: UserRef, contact: Contact)
case contactRequestRejected(user: UserRef)
case contactUpdated(user: UserRef, toContact: Contact)
case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember)
// TODO remove events below
case contactsSubscribed(server: String, contactRefs: [ContactRef])
case contactsDisconnected(server: String, contactRefs: [ContactRef])
@ -518,6 +522,7 @@ public enum ChatResponse: Decodable, Error {
case groupCreated(user: UserRef, groupInfo: GroupInfo)
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
case groupMembers(user: UserRef, group: Group)
@ -638,6 +643,7 @@ public enum ChatResponse: Decodable, Error {
case .acceptingContactRequest: return "acceptingContactRequest"
case .contactRequestRejected: return "contactRequestRejected"
case .contactUpdated: return "contactUpdated"
case .groupMemberUpdated: return "groupMemberUpdated"
case .contactsSubscribed: return "contactsSubscribed"
case .contactsDisconnected: return "contactsDisconnected"
case .contactSubSummary: return "contactSubSummary"
@ -657,6 +663,7 @@ public enum ChatResponse: Decodable, Error {
case .groupCreated: return "groupCreated"
case .sentGroupInvitation: return "sentGroupInvitation"
case .userAcceptedGroupSent: return "userAcceptedGroupSent"
case .groupLinkConnecting: return "groupLinkConnecting"
case .userDeletedMember: return "userDeletedMember"
case .leftMemberUser: return "leftMemberUser"
case .groupMembers: return "groupMembers"
@ -777,6 +784,7 @@ public enum ChatResponse: Decodable, Error {
case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact))
case .contactRequestRejected: return noDetails
case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact))
case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)")
case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions))
@ -796,6 +804,7 @@ public enum ChatResponse: Decodable, Error {
case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))")
case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .groupMembers(u, group): return withUser(u, String(describing: group))

View File

@ -422,8 +422,8 @@ public enum CustomTimeUnit {
public func timeText(_ seconds: Int?) -> String {
guard let seconds = seconds else { return "off" }
if seconds == 0 { return "0 sec" }
guard let seconds = seconds else { return NSLocalizedString("off", comment: "time to disappear") }
if seconds == 0 { return NSLocalizedString("0 sec", comment: "time to disappear") }
return CustomTimeUnit.toText(seconds: seconds)
}
@ -1867,8 +1867,8 @@ public struct GroupMember: Identifiable, Decodable {
)
}
public struct GroupMemberSettings: Decodable {
var showMessages: Bool
public struct GroupMemberSettings: Codable {
public var showMessages: Bool
}
public struct GroupMemberRef: Decodable {
@ -2090,7 +2090,7 @@ public struct ChatItem: Identifiable, Decodable {
public var memberConnected: GroupMember? {
switch chatDir {
case .groupRcv(let groupMember):
case let .groupRcv(groupMember):
switch content {
case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember
default: return nil
@ -2099,6 +2099,35 @@ public struct ChatItem: Identifiable, Decodable {
}
}
public var mergeCategory: CIMergeCategory? {
switch content {
case .rcvChatFeature: .chatFeature
case .sndChatFeature: .chatFeature
case .rcvGroupFeature: .chatFeature
case .sndGroupFeature: .chatFeature
case let.rcvGroupEvent(event):
switch event {
case .userRole: nil
case .userDeleted: nil
case .groupDeleted: nil
case .memberCreatedContact: nil
default: .rcvGroupEvent
}
case let .sndGroupEvent(event):
switch event {
case .userRole: nil
case .userLeft: nil
default: .sndGroupEvent
}
default:
if meta.itemDeleted == nil {
nil
} else {
chatDir.sent ? .sndItemDeleted : .rcvItemDeleted
}
}
}
private var showNtfDir: Bool {
return !chatDir.sent
}
@ -2176,7 +2205,7 @@ public struct ChatItem: Identifiable, Decodable {
public var memberDisplayName: String? {
get {
if case let .groupRcv(groupMember) = chatDir {
return groupMember.displayName
return groupMember.chatViewName
} else {
return nil
}
@ -2330,6 +2359,15 @@ public struct ChatItem: Identifiable, Decodable {
}
}
public enum CIMergeCategory {
case memberConnected
case rcvGroupEvent
case sndGroupEvent
case sndItemDeleted
case rcvItemDeleted
case chatFeature
}
public enum CIDirection: Decodable {
case directSnd
case directRcv
@ -2508,11 +2546,13 @@ public enum SndCIStatusProgress: String, Decodable {
public enum CIDeleted: Decodable {
case deleted(deletedTs: Date?)
case blocked(deletedTs: Date?)
case moderated(deletedTs: Date?, byGroupMember: GroupMember)
var id: String {
switch self {
case .deleted: return "deleted"
case .blocked: return "blocked"
case .moderated: return "moderated"
}
}
@ -2530,8 +2570,8 @@ protocol ItemContent {
public enum CIContent: Decodable, ItemContent {
case sndMsgContent(msgContent: MsgContent)
case rcvMsgContent(msgContent: MsgContent)
case sndDeleted(deleteMode: CIDeleteMode)
case rcvDeleted(deleteMode: CIDeleteMode)
case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used
case rcvDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used
case sndCall(status: CICallStatus, duration: Int)
case rcvCall(status: CICallStatus, duration: Int)
case rcvIntegrityError(msgError: MsgErrorType)

View File

@ -8,7 +8,7 @@ plugins {
}
android {
compileSdkVersion(33)
compileSdkVersion(34)
defaultConfig {
applicationId = "chat.simplex.app"
@ -144,7 +144,7 @@ dependencies {
androidTestImplementation("androidx.test.ext:junit:1.1.3")
androidTestImplementation("androidx.test.espresso:espresso-core:3.4.0")
//androidTestImplementation("androidx.compose.ui:ui-test-junit4:$compose_version")
debugImplementation("androidx.compose.ui:ui-tooling:${rootProject.extra["compose.version"] as String}")
debugImplementation("androidx.compose.ui:ui-tooling:1.4.3")
}
tasks {

View File

@ -36,7 +36,7 @@ buildscript {
extra.set("desktop.mac.signing.keychain", prop["desktop.mac.signing.keychain"] ?: extra.getOrNull("compose.desktop.mac.signing.keychain"))
extra.set("desktop.mac.notarization.apple_id", prop["desktop.mac.notarization.apple_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.appleID"))
extra.set("desktop.mac.notarization.password", prop["desktop.mac.notarization.password"] ?: extra.getOrNull("compose.desktop.mac.notarization.password"))
extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.ascProvider"))
extra.set("desktop.mac.notarization.team_id", prop["desktop.mac.notarization.team_id"] ?: extra.getOrNull("compose.desktop.mac.notarization.teamID"))
repositories {
google()
@ -45,7 +45,6 @@ buildscript {
dependencies {
classpath("com.android.tools.build:gradle:${rootProject.extra["gradle.plugin.version"]}")
classpath(kotlin("gradle-plugin", version = rootProject.extra["kotlin.version"] as String))
classpath("org.jetbrains.kotlin:kotlin-serialization:1.3.2")
classpath("dev.icerock.moko:resources-generator:0.23.0")
// NOTE: Do not place your application dependencies here; they belong

View File

@ -107,7 +107,7 @@ kotlin {
}
android {
compileSdkVersion(33)
compileSdkVersion(34)
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
defaultConfig {
minSdkVersion(26)
@ -138,6 +138,7 @@ buildConfig {
buildConfigField("String", "ANDROID_VERSION_NAME", "\"${extra["android.version_name"]}\"")
buildConfigField("int", "ANDROID_VERSION_CODE", "${extra["android.version_code"]}")
buildConfigField("String", "DESKTOP_VERSION_NAME", "\"${extra["desktop.version_name"]}\"")
buildConfigField("int", "DESKTOP_VERSION_CODE", "${extra["desktop.version_code"]}")
}
}

View File

@ -23,3 +23,5 @@ actual fun Modifier.desktopOnExternalDrag(
onImage: (Painter) -> Unit,
onText: (String) -> Unit
): Modifier = this
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this

View File

@ -36,7 +36,7 @@ actual fun shareFile(text: String, fileSource: CryptoFile) {
tmpFile.deleteOnExit()
ChatModel.filesToDelete.add(tmpFile)
decryptCryptoFile(getAppFilePath(fileSource.filePath), fileSource.cryptoArgs, tmpFile.absolutePath)
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(tmpFile.absolutePath)).toURI()
getAppFileUri(tmpFile.absolutePath)
} else {
getAppFileUri(fileSource.filePath)
}

View File

@ -7,6 +7,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
@Composable

View File

@ -1,48 +0,0 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.PopupProperties
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = this
actual interface DefaultExposedDropdownMenuBoxScope {
@Composable
actual fun DefaultExposedDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier,
content: @Composable ColumnScope.() -> Unit
) {
DropdownMenu(expanded, onDismissRequest, modifier, content = content)
}
@Composable
fun DropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
offset: DpOffset = DpOffset(0.dp, 0.dp),
properties: PopupProperties = PopupProperties(focusable = true),
content: @Composable ColumnScope.() -> Unit
) {
androidx.compose.material.DropdownMenu(expanded, onDismissRequest, modifier, offset, properties, content)
}
}
@Composable
actual fun DefaultExposedDropdownMenuBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit
) {
val scope = remember { object : DefaultExposedDropdownMenuBoxScope {} }
androidx.compose.material.ExposedDropdownMenuBox(expanded, onExpandedChange, modifier, content = {
scope.content()
})
}

View File

@ -152,7 +152,7 @@ private fun spannableStringToAnnotatedString(
}
actual fun getAppFileUri(fileName: String): URI =
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", File(getAppFilePath(fileName))).toURI()
FileProvider.getUriForFile(androidAppContext, "$APPLICATION_ID.provider", if (File(fileName).isAbsolute) File(fileName) else File(getAppFilePath(fileName))).toURI()
// https://developer.android.com/training/data-storage/shared/documents-files#bitmap
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {

View File

@ -137,6 +137,7 @@ object ChatModel {
fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
fun getGroupMember(groupMemberId: Long): GroupMember? = groupMembers.firstOrNull { it.groupMemberId == groupMemberId }
private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id }
fun addChat(chat: Chat) = chats.add(index = 0, chat)
@ -442,6 +443,78 @@ object ChatModel {
}
}
fun getChatItemIndexOrNull(cItem: ChatItem): Int? {
val reversedChatItems = chatItems.asReversed()
val index = reversedChatItems.indexOfFirst { it.id == cItem.id }
return if (index != -1) index else null
}
// this function analyses "connected" events and assumes that each member will be there only once
fun getConnectedMemberNames(cItem: ChatItem): Pair<Int, List<String>> {
var count = 0
val ns = mutableListOf<String>()
var idx = getChatItemIndexOrNull(cItem)
if (cItem.mergeCategory != null && idx != null) {
val reversedChatItems = chatItems.asReversed()
while (idx < reversedChatItems.size) {
val ci = reversedChatItems[idx]
if (ci.mergeCategory != cItem.mergeCategory) break
val m = ci.memberConnected
if (m != null) {
ns.add(m.displayName)
}
count++
idx++
}
}
return count to ns
}
// returns the index of the passed item and the next item (it has smaller index)
fun getNextChatItem(ci: ChatItem): Pair<Int?, ChatItem?> {
val i = getChatItemIndexOrNull(ci)
return if (i != null) {
val reversedChatItems = chatItems.asReversed()
i to if (i > 0) reversedChatItems[i - 1] else null
} else {
null to null
}
}
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
fun getPrevShownChatItem(ciIndex: Int?, ciCategory: CIMergeCategory?): Pair<Int?, ChatItem?> {
var i = ciIndex ?: return null to null
val reversedChatItems = chatItems.asReversed()
val fst = reversedChatItems.lastIndex
while (i < fst) {
i++
val ci = reversedChatItems[i]
if (ciCategory == null || ciCategory != ci.mergeCategory) {
return i - 1 to ci
}
}
return i to null
}
// returns the previous member in the same merge group and the count of members in this group
fun getPrevHiddenMember(member: GroupMember, range: IntRange): Pair<GroupMember?, Int> {
val reversedChatItems = chatItems.asReversed()
var prevMember: GroupMember? = null
val names: MutableSet<Long> = mutableSetOf()
for (i in range) {
val dir = reversedChatItems[i].chatDir
if (dir is CIDirection.GroupRcv) {
val m = dir.groupMember
if (prevMember == null && m.groupMemberId != member.groupMemberId) {
prevMember = m
}
names.add(m.groupMemberId)
}
}
return prevMember to names.size
}
// func popChat(_ id: String) {
// if let i = getChatIndex(id) {
// popChat_(i)
@ -474,7 +547,7 @@ object ChatModel {
}
// update current chat
return if (chatId.value == groupInfo.id) {
val memberIndex = groupMembers.indexOfFirst { it.id == member.id }
val memberIndex = groupMembers.indexOfFirst { it.groupMemberId == member.groupMemberId }
if (memberIndex >= 0) {
groupMembers[memberIndex] = member
false
@ -1090,11 +1163,11 @@ data class GroupMember (
val groupMemberId: Long,
val groupId: Long,
val memberId: String,
var memberRole: GroupMemberRole,
var memberCategory: GroupMemberCategory,
var memberStatus: GroupMemberStatus,
var memberSettings: GroupMemberSettings,
var invitedBy: InvitedBy,
val memberRole: GroupMemberRole,
val memberCategory: GroupMemberCategory,
val memberStatus: GroupMemberStatus,
val memberSettings: GroupMemberSettings,
val invitedBy: InvitedBy,
val localDisplayName: String,
val memberProfile: LocalProfile,
val memberContactId: Long? = null,
@ -1467,7 +1540,7 @@ data class ChatItem (
chatController.appPrefs.privacyEncryptLocalFiles.get()
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.displayName
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.chatViewName
else null
val isDeletedContent: Boolean get() =
@ -1491,6 +1564,29 @@ data class ChatItem (
else -> null
}
val mergeCategory: CIMergeCategory?
get() = when (content) {
is CIContent.RcvChatFeature,
is CIContent.SndChatFeature,
is CIContent.RcvGroupFeature,
is CIContent.SndGroupFeature -> CIMergeCategory.ChatFeature
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.UserRole, is RcvGroupEvent.UserDeleted, is RcvGroupEvent.GroupDeleted, is RcvGroupEvent.MemberCreatedContact -> null
else -> CIMergeCategory.RcvGroupEvent
}
is CIContent.SndGroupEventContent -> when (content.sndGroupEvent) {
is SndGroupEvent.UserRole, is SndGroupEvent.UserLeft -> null
else -> CIMergeCategory.SndGroupEvent
}
else -> {
if (meta.itemDeleted == null) {
null
} else {
if (chatDir.sent) CIMergeCategory.SndItemDeleted else CIMergeCategory.RcvItemDeleted
}
}
}
fun memberToModerate(chatInfo: ChatInfo): Pair<GroupInfo, GroupMember>? {
return if (chatInfo is ChatInfo.Group && chatDir is CIDirection.GroupRcv) {
val m = chatInfo.groupInfo.membership
@ -1695,6 +1791,15 @@ data class ChatItem (
}
}
enum class CIMergeCategory {
MemberConnected,
RcvGroupEvent,
SndGroupEvent,
SndItemDeleted,
RcvItemDeleted,
ChatFeature,
}
@Serializable
sealed class CIDirection {
@Serializable @SerialName("directSnd") class DirectSnd: CIDirection()
@ -1895,7 +2000,9 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("sndMsgContent") class SndMsgContent(override val msgContent: MsgContent): CIContent()
@Serializable @SerialName("rcvMsgContent") class RcvMsgContent(override val msgContent: MsgContent): CIContent()
// legacy - since v4.3.0 itemDeleted field is used
@Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
// legacy - since v4.3.0 itemDeleted field is used
@Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndCall") class SndCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvCall") class RcvCall(val status: CICallStatus, val duration: Int): CIContent() { override val msgContent: MsgContent? get() = null }

View File

@ -745,6 +745,9 @@ object ChatController {
}
}
suspend fun apiSetMemberSettings(groupId: Long, groupMemberId: Long, memberSettings: GroupMemberSettings): Boolean =
sendCommandOkResp(CC.ApiSetMemberSettings(groupId, groupMemberId, memberSettings))
suspend fun apiContactInfo(contactId: Long): Pair<ConnectionStats, Profile?>? {
val r = sendCmd(CC.APIContactInfo(contactId))
if (r is CR.ContactInfo) return r.connectionStats to r.customUserProfile
@ -1443,6 +1446,11 @@ object ChatController {
chatModel.updateChatInfo(cInfo)
}
}
is CR.GroupMemberUpdated -> {
if (active(r.user)) {
chatModel.upsertGroupMember(r.groupInfo, r.toMember)
}
}
is CR.ContactsMerged -> {
if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) {
if (chatModel.chatId.value == r.mergedContact.id) {
@ -1553,6 +1561,16 @@ object ChatController {
chatModel.removeChat(r.hostContact.activeConn.id)
}
}
is CR.GroupLinkConnecting -> {
if (!active(r.user)) return
chatModel.updateGroup(r.groupInfo)
val hostConn = r.hostMember.activeConn
if (hostConn != null) {
chatModel.dismissConnReqView(hostConn.id)
chatModel.removeChat(hostConn.id)
}
}
is CR.JoinedGroupMemberConnecting ->
if (active(r.user)) {
chatModel.upsertGroupMember(r.groupInfo, r.member)
@ -1911,6 +1929,7 @@ sealed class CC {
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC()
class APIContactInfo(val contactId: Long): CC()
class APIGroupMemberInfo(val groupId: Long, val groupMemberId: Long): CC()
class APISwitchContact(val contactId: Long): CC()
@ -2021,6 +2040,7 @@ sealed class CC {
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}"
is APIContactInfo -> "/_info @$contactId"
is APIGroupMemberInfo -> "/_info #$groupId $groupMemberId"
is APISwitchContact -> "/_switch @$contactId"
@ -2124,9 +2144,10 @@ sealed class CC {
is APITestProtoServer -> "testProtoServer"
is APISetChatItemTTL -> "apiSetChatItemTTL"
is APIGetChatItemTTL -> "apiGetChatItemTTL"
is APISetNetworkConfig -> "/apiSetNetworkConfig"
is APIGetNetworkConfig -> "/apiGetNetworkConfig"
is APISetChatSettings -> "/apiSetChatSettings"
is APISetNetworkConfig -> "apiSetNetworkConfig"
is APIGetNetworkConfig -> "apiGetNetworkConfig"
is APISetChatSettings -> "apiSetChatSettings"
is ApiSetMemberSettings -> "apiSetMemberSettings"
is APIContactInfo -> "apiContactInfo"
is APIGroupMemberInfo -> "apiGroupMemberInfo"
is APISwitchContact -> "apiSwitchContact"
@ -3379,6 +3400,7 @@ sealed class CR {
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR()
// TODO remove below
@Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List<ContactRef>): CR()
@Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List<ContactRef>): CR()
@ -3401,6 +3423,7 @@ sealed class CR {
@Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
@Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR()
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR()
@ -3515,6 +3538,7 @@ sealed class CR {
is AcceptingContactRequest -> "acceptingContactRequest"
is ContactRequestRejected -> "contactRequestRejected"
is ContactUpdated -> "contactUpdated"
is GroupMemberUpdated -> "groupMemberUpdated"
is ContactsSubscribed -> "contactsSubscribed"
is ContactsDisconnected -> "contactsDisconnected"
is ContactSubSummary -> "contactSubSummary"
@ -3534,6 +3558,7 @@ sealed class CR {
is GroupCreated -> "groupCreated"
is SentGroupInvitation -> "sentGroupInvitation"
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
is GroupLinkConnecting -> "groupLinkConnecting"
is UserDeletedMember -> "userDeletedMember"
is LeftMemberUser -> "leftMemberUser"
is GroupMembers -> "groupMembers"
@ -3646,6 +3671,7 @@ sealed class CR {
is AcceptingContactRequest -> withUser(user, json.encodeToString(contact))
is ContactRequestRejected -> withUser(user, noDetails())
is ContactUpdated -> withUser(user, json.encodeToString(toContact))
is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember")
is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions))
@ -3665,6 +3691,7 @@ sealed class CR {
is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo))
is GroupMembers -> withUser(user, json.encodeToString(group))

View File

@ -21,7 +21,7 @@ expect val appPlatform: AppPlatform
val appVersionInfo: Pair<String, Int?> = if (appPlatform == AppPlatform.ANDROID)
BuildConfigCommon.ANDROID_VERSION_NAME to BuildConfigCommon.ANDROID_VERSION_CODE
else
BuildConfigCommon.DESKTOP_VERSION_NAME to null
BuildConfigCommon.DESKTOP_VERSION_NAME to BuildConfigCommon.DESKTOP_VERSION_CODE
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {

View File

@ -20,3 +20,5 @@ expect fun Modifier.desktopOnExternalDrag(
onImage: (Painter) -> Unit = {},
onText: (String) -> Unit = {}
): Modifier
expect fun Modifier.onRightClick(action: () -> Unit): Modifier

View File

@ -24,6 +24,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chat.item.MarkdownText
import chat.simplex.common.views.helpers.*
@ -382,7 +383,7 @@ fun ChatItemInfoView(chatModel: ChatModel, ci: ChatItem, ciInfo: ChatItemInfo, d
private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List<MemberDeliveryStatus>): List<Pair<GroupMember, CIStatus>> {
return memberDeliveryStatuses.mapNotNull { mds ->
chatModel.groupMembers.firstOrNull { it.groupMemberId == mds.groupMemberId }?.let { mem ->
chatModel.getGroupMember(mds.groupMemberId)?.let { mem ->
mem to mds.memberDeliveryStatus
}
}

View File

@ -152,6 +152,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
hideKeyboard(view)
AudioPlayer.stop()
chatModel.chatId.value = null
chatModel.groupMembers.clear()
},
info = {
if (ModalManager.end.hasModalsOpen()) {
@ -212,7 +213,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
setGroupMembers(groupInfo, chatModel)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, close, close)
}
}
@ -263,6 +264,25 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
},
deleteMessages = { itemIds ->
if (itemIds.isNotEmpty()) {
val chatInfo = chat.chatInfo
withBGApi {
val deletedItems: ArrayList<ChatItem> = arrayListOf()
for (itemId in itemIds) {
val di = chatModel.controller.apiDeleteChatItem(
chatInfo.chatType, chatInfo.apiId, itemId, CIDeleteMode.cidmInternal
)?.deletedChatItem?.chatItem
if (di != null) {
deletedItems.add(di)
}
}
for (di in deletedItems) {
chatModel.removeChatItem(chatInfo, di)
}
}
}
},
receiveFile = { fileId, encrypted ->
withApi { chatModel.controller.receiveFile(user, fileId, encrypted) }
},
@ -442,6 +462,7 @@ fun ChatLayout(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
@ -517,7 +538,7 @@ fun ChatLayout(
) {
ChatItemsList(
chat, unreadCount, composeState, chatItems, searchValue,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage,
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, deleteMessages,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools,
@ -744,6 +765,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
showMemberInfo: (GroupInfo, GroupMember) -> Unit,
loadPrevMessages: (ChatInfo) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
@ -846,31 +868,27 @@ fun BoxWithConstraintsScope.ChatItemsList(
}
}
}
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val prevItem = if (i < reversedChatItems.lastIndex) reversedChatItems[i + 1] else null
val nextItem = if (i - 1 >= 0) reversedChatItems[i - 1] else null
fun getConnectedMemberNames(): List<String> {
val ns = mutableListOf<String>()
var idx = i
while (idx < reversedChatItems.size) {
val m = reversedChatItems[idx].memberConnected
if (m != null) {
ns.add(m.displayName)
} else {
break
}
idx++
}
return ns
}
if (cItem.memberConnected != null && nextItem?.memberConnected != null) {
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
Box(Modifier.size(0.dp)) {}
} else {
val revealed = remember { mutableStateOf(false) }
@Composable
fun ChatItemViewShortHand(cItem: ChatItem, range: IntRange?) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, range = range, deleteMessage = deleteMessage, deleteMessages = deleteMessages, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
}
@Composable
fun ChatItemView(cItem: ChatItem, range: IntRange?, prevItem: ChatItem?) {
val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null
if (chat.chatInfo is ChatInfo.Group) {
if (cItem.chatDir is CIDirection.GroupRcv) {
val member = cItem.chatDir.groupMember
if (showMemberImage(member, prevItem)) {
val (prevMember, memCount) =
if (range != null) {
chatModel.getPrevHiddenMember(member, range)
} else {
null to 1
}
if (prevItem == null || showMemberImage(member, prevItem) || prevMember != null) {
Column(
Modifier
.padding(top = 8.dp)
@ -880,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
if (cItem.content.showMemberName) {
Text(
member.displayName,
memberNames(member, prevMember, memCount),
Modifier.padding(start = MEMBER_IMAGE_SIZE + 10.dp),
style = TextStyle(fontSize = 13.5.sp, color = CurrentColors.value.colors.secondary)
)
@ -898,7 +916,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
MemberImage(member)
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
ChatItemViewShortHand(cItem, range)
}
}
} else {
@ -907,28 +925,45 @@ fun BoxWithConstraintsScope.ChatItemsList(
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
.then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
ChatItemViewShortHand(cItem, range)
}
}
} else {
Box(
Modifier
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
.then(swipeableModifier)
) {
ChatItemViewShortHand(cItem, range)
}
}
} else {
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
.then(swipeableModifier)
Modifier.padding(
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
ChatItemViewShortHand(cItem, range)
}
}
} else { // direct message
val sent = cItem.chatDir.sent
Box(
Modifier.padding(
start = if (sent && !voiceWithTransparentBack) 76.dp else 12.dp,
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
).then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
}
val (currIndex, nextItem) = chatModel.getNextChatItem(cItem)
val ciCategory = cItem.mergeCategory
if (ciCategory != null && ciCategory == nextItem?.mergeCategory) {
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
} else {
val (prevHidden, prevItem) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val range = chatViewItemsRange(currIndex, prevHidden)
if (revealed.value && range != null) {
reversedChatItems.subList(range.first, range.last + 1).forEachIndexed { index, ci ->
val prev = if (index + range.first == prevHidden) prevItem else reversedChatItems[index + range.first + 1]
ChatItemView(ci, null, prev)
}
} else {
ChatItemView(cItem, range, prevItem)
}
}
@ -1106,10 +1141,12 @@ fun PreloadItems(
}
}
fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean {
return prevItem == null || prevItem.chatDir is CIDirection.GroupSnd ||
(prevItem.chatDir is CIDirection.GroupRcv && prevItem.chatDir.groupMember.groupMemberId != member.groupMemberId)
}
private fun showMemberImage(member: GroupMember, prevItem: ChatItem?): Boolean =
when (val dir = prevItem?.chatDir) {
is CIDirection.GroupSnd -> true
is CIDirection.GroupRcv -> dir.groupMember.groupMemberId != member.groupMemberId
else -> false
}
val MEMBER_IMAGE_SIZE: Dp = 38.dp
@ -1206,6 +1243,29 @@ private fun markUnreadChatAsRead(activeChat: MutableState<Chat?>, chatModel: Cha
}
}
@Composable
private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String {
val name = member.displayName
val prevName = prevMember?.displayName
return if (prevName != null) {
if (memCount > 2) {
stringResource(MR.strings.group_members_n).format(name, prevName, memCount - 2)
} else {
stringResource(MR.strings.group_members_2).format(name, prevName)
}
} else {
name
}
}
fun chatViewItemsRange(currIndex: Int?, prevHidden: Int?): IntRange? =
if (currIndex != null && prevHidden != null && prevHidden > currIndex) {
currIndex..prevHidden
} else {
null
}
sealed class ProviderMedia {
data class Image(val data: ByteArray, val image: ImageBitmap): ProviderMedia()
data class Video(val uri: URI, val preview: String): ProviderMedia()
@ -1347,6 +1407,7 @@ fun PreviewChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },
@ -1418,6 +1479,7 @@ fun PreviewGroupChatLayout() {
showMemberInfo = { _, _ -> },
loadPrevMessages = { _ -> },
deleteMessage = { _, _ -> },
deleteMessages = {},
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },

View File

@ -4,10 +4,12 @@ import InfoRow
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionItemViewLongClickable
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.*
@ -31,6 +33,7 @@ import chat.simplex.common.views.usersettings.*
import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.*
import chat.simplex.res.MR
import kotlinx.coroutines.launch
@ -82,7 +85,7 @@ fun GroupChatInfoView(chatModel: ChatModel, groupLink: String?, groupLinkMemberR
member to null
}
ModalManager.end.showModalCloseable(true) { closeCurrent ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
GroupMemberInfoView(groupInfo, mem, stats, code, chatModel, closeCurrent) {
closeCurrent()
close()
@ -157,6 +160,23 @@ fun leaveGroupDialog(groupInfo: GroupInfo, chatModel: ChatModel, close: (() -> U
)
}
private fun removeMemberAlert(groupInfo: GroupInfo, mem: GroupMember) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.button_remove_member_question),
text = generalGetString(MR.strings.member_will_be_removed_from_group_cannot_be_undone),
confirmText = generalGetString(MR.strings.remove_member_confirmation),
onConfirm = {
withApi {
val updatedMember = chatModel.controller.apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
if (updatedMember != null) {
chatModel.upsertGroupMember(groupInfo, updatedMember)
}
}
},
destructive = true,
)
}
@Composable
fun GroupChatInfoLayout(
chat: Chat,
@ -238,8 +258,10 @@ fun GroupChatInfoLayout(
}
items(filteredMembers.value) { member ->
Divider()
SectionItemView({ showMemberInfo(member) }, minHeight = 54.dp) {
MemberRow(member)
val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable({ showMemberInfo(member) }, { showMenu.value = true }, minHeight = 54.dp) {
DropDownMenuForMember(member, groupInfo, showMenu)
MemberRow(member, onClick = { showMemberInfo(member) })
}
}
item {
@ -344,7 +366,7 @@ private fun AddMembersButton(tint: Color = MaterialTheme.colors.primary, onClick
}
@Composable
private fun MemberRow(member: GroupMember, user: Boolean = false) {
private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -> Unit)? = null) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -390,6 +412,29 @@ private fun MemberVerifiedShield() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.padding(end = 3.dp).size(16.dp), tint = MaterialTheme.colors.secondary)
}
@Composable
private fun DropDownMenuForMember(member: GroupMember, groupInfo: GroupInfo, showMenu: MutableState<Boolean>) {
DefaultDropdownMenu(showMenu) {
if (member.canBeRemoved(groupInfo)) {
ItemAction(stringResource(MR.strings.remove_member_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
removeMemberAlert(groupInfo, member)
showMenu.value = false
})
}
if (member.memberSettings.showMessages) {
ItemAction(stringResource(MR.strings.block_member_button), painterResource(MR.images.ic_back_hand), color = MaterialTheme.colors.error, onClick = {
blockMemberAlert(groupInfo, member)
showMenu.value = false
})
} else {
ItemAction(stringResource(MR.strings.unblock_member_button), painterResource(MR.images.ic_do_not_touch), onClick = {
unblockMemberAlert(groupInfo, member)
showMenu.value = false
})
}
}
}
@Composable
private fun GroupLinkButton(onClick: () -> Unit) {
SettingsActionItem(

View File

@ -96,6 +96,8 @@ fun GroupMemberInfoView(
connectViaAddress = { connReqUri ->
connectViaMemberAddressAlert(connReqUri)
},
blockMember = { blockMemberAlert(groupInfo, member) },
unblockMember = { unblockMemberAlert(groupInfo, member) },
removeMember = { removeMemberDialog(groupInfo, member, chatModel, close) },
onRoleSelected = {
if (it == newRole.value) return@GroupMemberInfoLayout
@ -162,7 +164,7 @@ fun GroupMemberInfoView(
},
verifyClicked = {
ModalManager.end.showModalCloseable { close ->
remember { derivedStateOf { chatModel.groupMembers.firstOrNull { it.memberId == member.memberId } } }.value?.let { mem ->
remember { derivedStateOf { chatModel.getGroupMember(member.groupMemberId) } }.value?.let { mem ->
VerifyCodeView(
mem.displayName,
connectionCode,
@ -224,6 +226,8 @@ fun GroupMemberInfoLayout(
openDirectChat: (Long) -> Unit,
createMemberContact: () -> Unit,
connectViaAddress: (String) -> Unit,
blockMember: () -> Unit,
unblockMember: () -> Unit,
removeMember: () -> Unit,
onRoleSelected: (GroupMemberRole) -> Unit,
switchMemberAddress: () -> Unit,
@ -338,9 +342,14 @@ fun GroupMemberInfoLayout(
}
}
if (member.canBeRemoved(groupInfo)) {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
if (member.memberSettings.showMessages) {
BlockMemberButton(blockMember)
} else {
UnblockMemberButton(unblockMember)
}
if (member.canBeRemoved(groupInfo)) {
RemoveMemberButton(removeMember)
}
}
@ -396,6 +405,26 @@ fun GroupMemberInfoHeader(member: GroupMember) {
}
}
@Composable
fun BlockMemberButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_back_hand),
stringResource(MR.strings.block_member_button),
click = onClick,
textColor = Color.Red,
iconColor = Color.Red,
)
}
@Composable
fun UnblockMemberButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_do_not_touch),
stringResource(MR.strings.unblock_member_button),
click = onClick
)
}
@Composable
fun RemoveMemberButton(onClick: () -> Unit) {
SettingsActionItem(
@ -485,6 +514,43 @@ fun connectViaMemberAddressAlert(connReqUri: String) {
}
}
fun blockMemberAlert(gInfo: GroupInfo, mem: GroupMember) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.block_member_question),
text = generalGetString(MR.strings.block_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.block_member_confirmation),
onConfirm = {
toggleShowMemberMessages(gInfo, mem, false)
},
destructive = true,
)
}
fun unblockMemberAlert(gInfo: GroupInfo, mem: GroupMember) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.unblock_member_question),
text = generalGetString(MR.strings.unblock_member_desc).format(mem.chatViewName),
confirmText = generalGetString(MR.strings.unblock_member_confirmation),
onConfirm = {
toggleShowMemberMessages(gInfo, mem, true)
},
)
}
fun toggleShowMemberMessages(gInfo: GroupInfo, member: GroupMember, showMessages: Boolean) {
val updatedMemberSettings = member.memberSettings.copy(showMessages = showMessages)
updateMemberSettings(gInfo, member, updatedMemberSettings)
}
fun updateMemberSettings(gInfo: GroupInfo, member: GroupMember, memberSettings: GroupMemberSettings) {
withBGApi {
val success = ChatController.apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings)
if (success) {
ChatModel.upsertGroupMember(gInfo, member.copy(memberSettings = memberSettings))
}
}
}
@Preview
@Composable
fun PreviewGroupMemberInfoLayout() {
@ -500,6 +566,8 @@ fun PreviewGroupMemberInfoLayout() {
openDirectChat = {},
createMemberContact = {},
connectViaAddress = {},
blockMember = {},
unblockMember = {},
removeMember = {},
onRoleSelected = {},
switchMemberAddress = {},

View File

@ -1,19 +1,119 @@
package chat.simplex.common.views.chat.item
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.Feature
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.platform.onRightClick
@Composable
fun CIChatFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color,
icon: Painter? = null,
revealed: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
) {
val merged = if (!revealed.value) mergedFeatures(chatItem) else emptyList()
Box(
Modifier
.combinedClickable(
onLongClick = { showMenu.value = true },
onClick = {}
)
.onRightClick { showMenu.value = true }
) {
if (!revealed.value && merged != null) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
merged.forEach {
FeatureIconView(it)
}
}
} else {
FullFeatureView(chatItem, feature, iconColor, icon)
}
}
}
private data class FeatureInfo(
val icon: PainterBox,
val color: Color,
val param: String?
)
private class PainterBox(
val featureName: String,
val icon: Painter,
) {
override fun hashCode(): Int = featureName.hashCode()
override fun equals(other: Any?): Boolean = other is PainterBox && featureName == other.featureName
}
@Composable
private fun Feature.toFeatureInfo(color: Color, param: Int?, type: String): FeatureInfo =
FeatureInfo(
icon = PainterBox(type, iconFilled()),
color = color,
param = if (this.hasParam && param != null) timeText(param) else null
)
@Composable
private fun mergedFeatures(chatItem: ChatItem): List<FeatureInfo>? {
val m = ChatModel
val fs: ArrayList<FeatureInfo> = arrayListOf()
val icons: MutableSet<PainterBox> = mutableSetOf()
var i = getChatItemIndexOrNull(chatItem)
if (i != null) {
val reversedChatItems = m.chatItems.asReversed()
while (i < reversedChatItems.size) {
val f = featureInfo(reversedChatItems[i]) ?: break
if (!icons.contains(f.icon)) {
fs.add(0, f)
icons.add(f.icon)
}
i++
}
}
return if (fs.size > 1) fs else null
}
@Composable
private fun featureInfo(ci: ChatItem): FeatureInfo? =
when (ci.content) {
is CIContent.RcvChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name)
is CIContent.SndChatFeature -> ci.content.feature.toFeatureInfo(ci.content.enabled.iconColor, ci.content.param, ci.content.feature.name)
is CIContent.RcvGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name)
is CIContent.SndGroupFeature -> ci.content.groupFeature.toFeatureInfo(ci.content.preference.enable.iconColor, ci.content.param, ci.content.groupFeature.name)
else -> null
}
@Composable
private fun FeatureIconView(f: FeatureInfo) {
val icon = @Composable { Icon(f.icon.icon, null, Modifier.size(20.dp), tint = f.color) }
if (f.param != null) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) {
icon()
Text(chatEventText(f.param, ""), maxLines = 1)
}
} else {
icon()
}
}
@Composable
private fun FullFeatureView(
chatItem: ChatItem,
feature: Feature,
iconColor: Color,
@ -24,7 +124,7 @@ fun CIChatFeatureView(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(18.dp), tint = iconColor)
Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(20.dp), tint = iconColor)
Text(
chatEventText(chatItem),
Modifier,

View File

@ -1,11 +1,9 @@
package chat.simplex.common.views.chat.item
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.dp
@ -14,12 +12,7 @@ import chat.simplex.common.ui.theme.*
@Composable
fun CIEventView(text: AnnotatedString) {
Row(
Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(text, style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
Text(text, Modifier.padding(horizontal = 6.dp, vertical = 6.dp), style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp))
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,

View File

@ -21,9 +21,8 @@ import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.chat.ComposeContextItem
import chat.simplex.common.views.chat.ComposeState
import chat.simplex.res.MR
import kotlinx.datetime.Clock
@ -47,7 +46,10 @@ fun ChatItemView(
imageProvider: (() -> ImageGalleryProvider)? = null,
useLinkPreviews: Boolean,
linkMode: SimplexLinkMode,
revealed: MutableState<Boolean>,
range: IntRange?,
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
@ -63,14 +65,12 @@ fun ChatItemView(
findModelMember: (String) -> GroupMember?,
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
getConnectedMemberNames: (() -> List<String>)? = null,
developerTools: Boolean,
) {
val uriHandler = LocalUriHandler.current
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
val showMenu = remember { mutableStateOf(false) }
val revealed = remember { mutableStateOf(false) }
val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) }
val onLinkLongClick = { _: String -> showMenu.value = true }
val live = composeState.value.liveMessage != null
@ -178,61 +178,75 @@ fun ChatItemView(
fun MsgContentItemDropdownMenu() {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = cItem.file)
DefaultDropdownMenu(showMenu) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
}
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
val fileSource = getLoadedFileSource(cItem.file)
when {
fileSource != null -> shareFile(cItem.text, fileSource)
else -> clipboard.shareText(cItem.content.text)
if (cItem.content.msgContent != null) {
if (cInfo.featureEnabled(ChatFeature.Reactions) && cItem.allowAddReaction) {
MsgReactionsMenu()
}
showMenu.value = false
})
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
copyItemToClipboard(cItem, clipboard)
showMenu.value = false
})
if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
if (cItem.meta.itemDeleted == null && !live) {
ItemAction(stringResource(MR.strings.reply_verb), painterResource(MR.images.ic_reply), onClick = {
if (composeState.value.editing) {
composeState.value = ComposeState(contextItem = ComposeContextItem.QuotedItem(cItem), useLinkPreviews = useLinkPreviews)
} else {
composeState.value = composeState.value.copy(contextItem = ComposeContextItem.QuotedItem(cItem))
}
showMenu.value = false
})
}
val clipboard = LocalClipboardManager.current
ItemAction(stringResource(MR.strings.share_verb), painterResource(MR.images.ic_share), onClick = {
val fileSource = getLoadedFileSource(cItem.file)
when {
fileSource != null -> shareFile(cItem.text, fileSource)
else -> clipboard.shareText(cItem.content.text)
}
showMenu.value = false
})
}
if (cItem.meta.itemDeleted != null && revealed.value) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_visibility_off),
onClick = {
revealed.value = false
ItemAction(stringResource(MR.strings.copy_verb), painterResource(MR.images.ic_content_copy), onClick = {
copyItemToClipboard(cItem, clipboard)
showMenu.value = false
})
if ((cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) && getLoadedFilePath(cItem.file) != null) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
}
if (cItem.meta.editable && cItem.content.msgContent !is MsgContent.MCVoice && !live) {
ItemAction(stringResource(MR.strings.edit_verb), painterResource(MR.images.ic_edit_filled), onClick = {
composeState.value = ComposeState(editingItem = cItem, useLinkPreviews = useLinkPreviews)
showMenu.value = false
}
)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
})
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
if (revealed.value) {
HideItemAction(revealed, showMenu)
}
if (cItem.meta.itemDeleted == null && cItem.file != null && cItem.file.cancelAction != null) {
CancelFileItemAction(cItem.file.fileId, showMenu, cancelFile = cancelFile, cancelAction = cItem.file.cancelAction)
}
if (!(live && cItem.meta.isLive)) {
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
val groupInfo = cItem.memberToModerate(cInfo)?.first
if (groupInfo != null) {
ModerateItemAction(cItem, questionText = moderateMessageQuestionText(), showMenu, deleteMessage)
}
} else if (cItem.meta.itemDeleted != null) {
if (revealed.value) {
HideItemAction(revealed, showMenu)
} else if (!cItem.isDeletedContent) {
RevealItemAction(revealed, showMenu)
} else if (range != null) {
ExpandItemAction(revealed, showMenu)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
} else if (cItem.isDeletedContent) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
} else if (cItem.mergeCategory != null) {
if (revealed.value) {
ShrinkItemAction(revealed, showMenu)
} else {
ExpandItemAction(revealed, showMenu)
}
}
}
}
@ -241,25 +255,18 @@ fun ChatItemView(
fun MarkedDeletedItemDropdownMenu() {
DefaultDropdownMenu(showMenu) {
if (!cItem.isDeletedContent) {
ItemAction(
stringResource(MR.strings.reveal_verb),
painterResource(MR.images.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
}
)
RevealItemAction(revealed, showMenu)
}
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
}
@Composable
fun ContentItem() {
val mc = cItem.content.msgContent
if (cItem.meta.itemDeleted != null && !revealed.value) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed)
MarkedDeletedItemDropdownMenu()
} else {
if (cItem.quotedItem == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) {
@ -281,7 +288,7 @@ fun ChatItemView(
DeletedItemView(cItem, cInfo.timedMessagesTTL)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = deleteMessageQuestionText(), deleteMessage)
DeleteItemAction(cItem, revealed, showMenu, questionText = deleteMessageQuestionText(), deleteMessage, deleteMessages)
}
}
@ -289,9 +296,32 @@ fun ChatItemView(
CICallItemView(cInfo, cItem, status, duration, acceptCall)
}
fun mergedGroupEventText(chatItem: ChatItem): String? {
val (count, ns) = chatModel.getConnectedMemberNames(chatItem)
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])
ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2])
ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2)
else -> ""
}
return if (count <= 1) {
null
} else if (ns.isEmpty()) {
generalGetString(MR.strings.rcv_group_events_count).format(count)
} else if (count > ns.size) {
members + " " + generalGetString(MR.strings.rcv_group_and_other_events).format(count - ns.size)
} else {
members
}
}
fun eventItemViewText(): AnnotatedString {
val memberDisplayName = cItem.memberDisplayName
return if (memberDisplayName != null) {
val t = mergedGroupEventText(cItem)
return if (!revealed.value && t != null) {
chatEventText(t, cItem.timestampText)
} else if (memberDisplayName != null) {
buildAnnotatedString {
withStyle(chatEventStyle) { append(memberDisplayName) }
append(" ")
@ -305,35 +335,12 @@ fun ChatItemView(
CIEventView(eventItemViewText())
}
fun membersConnectedText(): String? {
return if (getConnectedMemberNames != null) {
val ns = getConnectedMemberNames()
when {
ns.size > 3 -> String.format(generalGetString(MR.strings.rcv_group_event_n_members_connected), ns[0], ns[1], ns.size - 2)
ns.size == 3 -> String.format(generalGetString(MR.strings.rcv_group_event_3_members_connected), ns[0], ns[1], ns[2])
ns.size == 2 -> String.format(generalGetString(MR.strings.rcv_group_event_2_members_connected), ns[0], ns[1])
else -> null
}
} else {
null
}
}
fun membersConnectedItemText(): AnnotatedString {
val t = membersConnectedText()
return if (t != null) {
chatEventText(t, cItem.timestampText)
} else {
eventItemViewText()
}
}
@Composable
fun ModeratedItem() {
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL)
MarkedDeletedItemView(cItem, cInfo.timedMessagesTTL, revealed)
DefaultDropdownMenu(showMenu) {
ItemInfoAction(cInfo, cItem, showItemDetails, showMenu)
DeleteItemAction(cItem, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage)
DeleteItemAction(cItem, revealed, showMenu, questionText = generalGetString(MR.strings.delete_message_cannot_be_undone_warning), deleteMessage, deleteMessages)
}
}
@ -352,26 +359,61 @@ fun ChatItemView(
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember)
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvDirectEventContent -> EventItemView()
is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText())
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)
else -> EventItemView()
is CIContent.RcvDirectEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupEventContent -> {
when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)
else -> EventItemView()
}
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.RcvConnEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.SndConnEventContent -> {
EventItemView()
MsgContentItemDropdownMenu()
}
is CIContent.RcvChatFeature -> {
CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndChatFeature -> {
CIChatFeatureView(cItem, c.feature, c.enabled.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupEventContent -> EventItemView()
is CIContent.RcvConnEventContent -> EventItemView()
is CIContent.SndConnEventContent -> EventItemView()
is CIContent.RcvChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.SndChatFeature -> CIChatFeatureView(cItem, c.feature, c.enabled.iconColor)
is CIContent.RcvChatPreference -> {
val ct = if (cInfo is ChatInfo.Direct) cInfo.contact else null
CIFeaturePreferenceView(cItem, ct, c.feature, c.allowed, acceptFeature)
}
is CIContent.SndChatPreference -> CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon,)
is CIContent.RcvGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.SndGroupFeature -> CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor)
is CIContent.RcvChatFeatureRejected -> CIChatFeatureView(cItem, c.feature, Color.Red)
is CIContent.RcvGroupFeatureRejected -> CIChatFeatureView(cItem, c.groupFeature, Color.Red)
is CIContent.SndChatPreference -> {
CIChatFeatureView(cItem, c.feature, MaterialTheme.colors.secondary, icon = c.feature.icon, revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupFeature -> {
CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndGroupFeature -> {
CIChatFeatureView(cItem, c.groupFeature, c.preference.enable.iconColor, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvChatFeatureRejected -> {
CIChatFeatureView(cItem, c.feature, Color.Red, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.RcvGroupFeatureRejected -> {
CIChatFeatureView(cItem, c.groupFeature, Color.Red, revealed = revealed, showMenu = showMenu)
MsgContentItemDropdownMenu()
}
is CIContent.SndModerated -> ModeratedItem()
is CIContent.RcvModerated -> ModeratedItem()
is CIContent.InvalidJSON -> CIInvalidJSONView(c.json)
@ -430,16 +472,38 @@ fun ItemInfoAction(
@Composable
fun DeleteItemAction(
cItem: ChatItem,
revealed: MutableState<Boolean>,
showMenu: MutableState<Boolean>,
questionText: String,
deleteMessage: (Long, CIDeleteMode) -> Unit
deleteMessage: (Long, CIDeleteMode) -> Unit,
deleteMessages: (List<Long>) -> Unit,
) {
ItemAction(
stringResource(MR.strings.delete_verb),
painterResource(MR.images.ic_delete),
onClick = {
showMenu.value = false
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
if (!revealed.value && cItem.meta.itemDeleted != null) {
val currIndex = chatModel.getChatItemIndexOrNull(cItem)
val ciCategory = cItem.mergeCategory
if (currIndex != null && ciCategory != null) {
val (prevHidden, _) = chatModel.getPrevShownChatItem(currIndex, ciCategory)
val range = chatViewItemsRange(currIndex, prevHidden)
if (range != null) {
val itemIds: ArrayList<Long> = arrayListOf()
for (i in range) {
itemIds.add(chatModel.chatItems.asReversed()[i].id)
}
deleteMessagesAlertDialog(itemIds, generalGetString(MR.strings.delete_message_mark_deleted_warning), deleteMessages = deleteMessages)
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
}
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
}
} else {
deleteMessageAlertDialog(cItem, questionText, deleteMessage = deleteMessage)
}
},
color = Color.Red
)
@ -463,6 +527,54 @@ fun ModerateItemAction(
)
}
@Composable
private fun RevealItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.reveal_verb),
painterResource(MR.images.ic_visibility),
onClick = {
revealed.value = true
showMenu.value = false
}
)
}
@Composable
private fun HideItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_visibility_off),
onClick = {
revealed.value = false
showMenu.value = false
}
)
}
@Composable
private fun ExpandItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.expand_verb),
painterResource(MR.images.ic_expand_all),
onClick = {
revealed.value = true
showMenu.value = false
},
)
}
@Composable
private fun ShrinkItemAction(revealed: MutableState<Boolean>, showMenu: MutableState<Boolean>) {
ItemAction(
stringResource(MR.strings.hide_verb),
painterResource(MR.images.ic_collapse_all),
onClick = {
revealed.value = false
showMenu.value = false
},
)
}
@Composable
fun ItemAction(text: String, icon: Painter, onClick: () -> Unit, color: Color = Color.Unspecified) {
val finalColor = if (color == Color.Unspecified) {
@ -542,6 +654,26 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMes
)
}
fun deleteMessagesAlertDialog(itemIds: List<Long>, questionText: String, deleteMessages: (List<Long>) -> Unit) {
AlertManager.shared.showAlertDialogButtons(
title = generalGetString(MR.strings.delete_messages__question).format(itemIds.size),
text = questionText,
buttons = {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 2.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = {
deleteMessages(itemIds)
AlertManager.shared.hideAlert()
}) { Text(stringResource(MR.strings.for_me_only), color = MaterialTheme.colors.error) }
}
}
)
}
fun moderateMessageAlertDialog(chatItem: ChatItem, questionText: String, deleteMessage: (Long, CIDeleteMode) -> Unit) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.delete_member_message__question),
@ -575,7 +707,10 @@ fun PreviewChatItemView() {
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },
@ -606,7 +741,10 @@ fun PreviewChatItemViewDeletedContent() {
useLinkPreviews = true,
linkMode = SimplexLinkMode.DESCRIPTION,
composeState = remember { mutableStateOf(ComposeState(useLinkPreviews = true)) },
revealed = remember { mutableStateOf(false) },
range = 0..1,
deleteMessage = { _, _ -> },
deleteMessages = { _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = { _, _ -> },

View File

@ -20,10 +20,9 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.base64ToBitmap
import chat.simplex.common.views.chat.MEMBER_IMAGE_SIZE
import chat.simplex.res.MR
import kotlin.math.min
@ -202,10 +201,16 @@ fun FramedItemView(
Column(Modifier.width(IntrinsicSize.Max)) {
PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) {
if (ci.meta.itemDeleted != null) {
if (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))
} else {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
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))
}
else -> {
FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete))
}
}
} else if (ci.meta.isLive) {
FramedItemHeader(stringResource(MR.strings.live), false)

View File

@ -32,7 +32,12 @@ interface ImageGalleryProvider {
@Composable
fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> Unit) {
val provider = remember { imageProvider() }
val pagerState = rememberPagerState(provider.initialIndex)
val pagerState = rememberPagerState(
initialPage = provider.initialIndex,
initialPageOffsetFraction = 0f
) {
provider.totalMediaSize.value
}
val goBack = { provider.onDismiss(pagerState.currentPage); close() }
BackHandler(onBack = goBack)
// Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank,
@ -138,7 +143,7 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () ->
}
}
if (appPlatform.isAndroid) {
HorizontalPager(pageCount = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Content(index) }
HorizontalPager(state = pagerState) { index -> Content(index) }
} else {
Content(pagerState.currentPage)
}

View File

@ -2,25 +2,25 @@ package chat.simplex.common.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.runtime.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.CIDeleted
import chat.simplex.common.model.ChatItem
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull
import chat.simplex.common.ui.theme.*
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?) {
fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, revealed: MutableState<Boolean>) {
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Surface(
@ -32,11 +32,7 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f, false)) {
if (ci.meta.itemDeleted is CIDeleted.Moderated) {
MarkedDeletedText(String.format(generalGetString(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName))
} else {
MarkedDeletedText(generalGetString(MR.strings.marked_deleted_description))
}
MergedMarkedDeletedText(ci, revealed)
}
CIMetaView(ci, timedMessagesTTL)
}
@ -44,7 +40,41 @@ fun MarkedDeletedItemView(ci: ChatItem, timedMessagesTTL: Int?) {
}
@Composable
private fun MarkedDeletedText(text: String) {
private fun MergedMarkedDeletedText(chatItem: ChatItem, revealed: MutableState<Boolean>) {
var i = getChatItemIndexOrNull(chatItem)
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 deleted = 0
val moderatedBy: MutableSet<String> = mutableSetOf()
while (i < reversedChatItems.size) {
val ci = reversedChatItems.getOrNull(i)
if (ci?.mergeCategory != ciCategory) break
when (val itemDeleted = ci.meta.itemDeleted ?: break) {
is CIDeleted.Moderated -> {
moderated += 1
moderatedBy.add(itemDeleted.byGroupMember.displayName)
}
is CIDeleted.Blocked -> blocked += 1
is CIDeleted.Deleted -> deleted += 1
}
i++
}
val total = moderated + blocked + deleted
if (total <= 1)
markedDeletedText(chatItem.meta)
else if (total == moderated)
stringResource(MR.strings.moderated_items_description).format(total, moderatedBy.joinToString(", "))
else if (total == blocked)
stringResource(MR.strings.blocked_items_description).format(total)
else
stringResource(MR.strings.marked_deleted_items_description).format(total)
} else {
markedDeletedText(chatItem.meta)
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(fontSize = 12.sp, fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(text) }
@ -56,6 +86,16 @@ private fun MarkedDeletedText(text: String) {
)
}
private fun markedDeletedText(meta: CIMeta): String =
when (meta.itemDeleted) {
is CIDeleted.Moderated ->
String.format(generalGetString(MR.strings.moderated_item_description), meta.itemDeleted.byGroupMember.displayName)
is CIDeleted.Blocked ->
generalGetString(MR.strings.blocked_item_description)
else ->
generalGetString(MR.strings.marked_deleted_description)
}
@Preview/*(
uiMode = Configuration.UI_MODE_NIGHT_YES,
name = "Dark Mode"

View File

@ -11,26 +11,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
expect fun Modifier.onRightClick(action: () -> Unit): Modifier
expect interface DefaultExposedDropdownMenuBoxScope {
@Composable
open fun DefaultExposedDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
)
}
@Composable
expect fun DefaultExposedDropdownMenuBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit
)
@Composable
fun DefaultDropdownMenu(
showMenu: MutableState<Boolean>,
@ -55,7 +35,7 @@ fun DefaultDropdownMenu(
}
@Composable
fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
fun ExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
expanded: MutableState<Boolean>,
modifier: Modifier = Modifier,
dropdownMenuItems: (@Composable () -> Unit)?
@ -63,7 +43,7 @@ fun DefaultExposedDropdownMenuBoxScope.DefaultExposedDropdownMenu(
MaterialTheme(
shapes = MaterialTheme.shapes.copy(medium = RoundedCornerShape(corner = CornerSize(25.dp)))
) {
DefaultExposedDropdownMenu(
ExposedDropdownMenu(
modifier = Modifier
.widthIn(min = 200.dp)
.background(MaterialTheme.colors.surface)

View File

@ -29,7 +29,7 @@ fun <T> ExposedDropDownSettingRow(
) {
SettingsActionItemWithContent(icon, title, iconColor = iconTint, disabled = !enabled.value) {
val expanded = remember { mutableStateOf(false) }
DefaultExposedDropdownMenuBox(
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = {
expanded.value = !expanded.value && enabled.value

View File

@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.platform.windowWidth
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
@ -98,6 +99,34 @@ fun SectionItemView(
}
}
@Composable
fun SectionItemViewLongClickable(
click: () -> Unit,
longClick: () -> Unit,
minHeight: Dp = 46.dp,
disabled: Boolean = false,
extraPadding: Boolean = false,
padding: PaddingValues = if (extraPadding)
PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING)
else
PaddingValues(horizontal = DEFAULT_PADDING),
content: (@Composable RowScope.() -> Unit)
) {
val modifier = Modifier
.fillMaxWidth()
.sizeIn(minHeight = minHeight)
Row(
if (disabled) {
modifier.padding(padding)
} else {
modifier.combinedClickable(onClick = click, onLongClick = longClick).onRightClick(longClick).padding(padding)
},
verticalAlignment = Alignment.CenterVertically
) {
content()
}
}
@Composable
fun SectionItemViewWithIcon(
click: (() -> Unit)? = null,

View File

@ -254,7 +254,7 @@ fun IntSettingRow(title: String, selection: MutableState<Int>, values: List<Int>
Text(title)
DefaultExposedDropdownMenuBox(
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = {
expanded.value = !expanded.value
@ -313,7 +313,7 @@ fun TimeoutSettingRow(title: String, selection: MutableState<Long>, values: List
Text(title)
DefaultExposedDropdownMenuBox(
ExposedDropdownMenuBox(
expanded = expanded.value,
onExpandedChange = {
expanded.value = !expanded.value

View File

@ -24,6 +24,7 @@ fun VersionInfoView(info: CoreVersionInfo) {
Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.ANDROID_VERSION_CODE))
} else {
Text(String.format(stringResource(MR.strings.app_version_name), BuildConfigCommon.DESKTOP_VERSION_NAME))
Text(String.format(stringResource(MR.strings.app_version_code), BuildConfigCommon.DESKTOP_VERSION_CODE))
}
Text(String.format(stringResource(MR.strings.core_version), info.version))
val simplexmqCommit = if (info.simplexmqCommit.length >= 7) info.simplexmqCommit.substring(startIndex = 0, endIndex = 7) else info.simplexmqCommit

View File

@ -30,7 +30,11 @@
<!-- Item Content - ChatModel.kt -->
<string name="deleted_description">deleted</string>
<string name="marked_deleted_description">marked deleted</string>
<string name="marked_deleted_items_description">%d messages marked deleted</string>
<string name="moderated_item_description">moderated by %s</string>
<string name="moderated_items_description">%d messages moderated by %s</string>
<string name="blocked_item_description">blocked</string>
<string name="blocked_items_description">%d messages blocked</string>
<string name="sending_files_not_yet_supported">sending files is not supported yet</string>
<string name="receiving_files_not_yet_supported">receiving files is not supported yet</string>
<string name="sender_you_pronoun">you</string>
@ -243,7 +247,9 @@
<string name="hide_verb">Hide</string>
<string name="allow_verb">Allow</string>
<string name="moderate_verb">Moderate</string>
<string name="expand_verb">Expand</string>
<string name="delete_message__question">Delete message?</string>
<string name="delete_messages__question">Delete %d messages?</string>
<string name="delete_message_cannot_be_undone_warning">Message will be deleted - this cannot be undone!</string>
<string name="delete_message_mark_deleted_warning">Message will be marked for deletion. The recipient(s) will be able to reveal this message.</string>
<string name="delete_member_message__question">Delete member message?</string>
@ -1132,9 +1138,14 @@
<string name="snd_group_event_user_left">you left</string>
<string name="snd_group_event_group_profile_updated">group profile updated</string>
<string name="rcv_group_event_1_member_connected">%s connected</string>
<string name="rcv_group_event_2_members_connected">%s and %s connected</string>
<string name="rcv_group_event_3_members_connected">%s, %s and %s connected</string>
<string name="rcv_group_event_n_members_connected">%s, %s and %d other members connected</string>
<string name="rcv_group_events_count">%d group events</string>
<string name="rcv_group_and_other_events">and %d other events</string>
<string name="group_members_2">%s and %s</string>
<string name="group_members_n">%s, %s and %d members</string>
<string name="rcv_group_event_open_chat">Open</string>
@ -1250,10 +1261,21 @@
<string name="recipient_colon_delivery_status">%s: %s</string>
<!-- GroupMemberInfoView.kt -->
<string name="button_remove_member_question">Remove member?</string>
<string name="button_remove_member">Remove member</string>
<string name="button_send_direct_message">Send direct message</string>
<string name="member_will_be_removed_from_group_cannot_be_undone">Member will be removed from group - this cannot be undone!</string>
<string name="remove_member_confirmation">Remove</string>
<string name="remove_member_button">Remove member</string>
<string name="block_member_question">Block member?</string>
<string name="block_member_button">Block member</string>
<string name="block_member_confirmation">Block</string>
<string name="block_member_desc">All new messages from %s will be hidden!</string>
<string name="unblock_member_question">Unblock member?</string>
<string name="unblock_member_button">Unblock member</string>
<string name="unblock_member_confirmation">Unblock</string>
<string name="unblock_member_desc">Messages from %s will be shown!</string>
<string name="member_info_section_title_member">MEMBER</string>
<string name="role_in_group">Role</string>
<string name="change_role">Change role</string>

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M504.5-45q-92 0-169.75-46T210-216.5l-147.5-247 19-19.5q15-15 36-16.5T156-489l129 93v-410.5q0-10.925 8.154-19.713 8.153-8.787 20.75-8.787 11.096 0 19.846 8.787 8.75 8.788 8.75 19.713v522l-185-134L258-250q37.5 68.5 103.318 108 65.817 39.5 143.182 39.5 112.792 0 192.896-78.104Q777.5-258.708 777.5-371v-395.688q0-10.812 8.154-19.562 8.153-8.75 20.75-8.75 11.096 0 19.846 8.787Q835-777.425 835-766.5V-371q0 136-96.832 231Q641.335-45 504.5-45Zm-55-446.5v-395q0-10.925 8.654-19.713 8.653-8.787 20.25-8.787 12.096 0 20.346 8.787Q507-897.425 507-886.5v395h-57.5Zm164.5 0v-355q0-10.925 8.154-19.713 8.153-8.787 20.75-8.787 11.096 0 19.846 8.787 8.75 8.788 8.75 19.713v355H614ZM468-297Z"/></svg>

After

Width:  |  Height:  |  Size: 782 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m291.5-89.5-40.5-40 229-229 229 229-40.5 40L480-278 291.5-89.5Zm188.5-513-229-229 40.5-40.5L480-683.5 668.5-872l40.5 40.5-229 229Z"/></svg>

After

Width:  |  Height:  |  Size: 236 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M835-207.5 777.5-265v-501.5q0-11.675 8.425-20.088 8.426-8.412 20.5-8.412 12.075 0 20.325 8.412Q835-778.175 835-766.5v559ZM342.5-699 285-756.5v-50q0-11.675 8.425-20.088 8.426-8.412 20.5-8.412 12.075 0 20.325 8.412 8.25 8.413 8.25 20.088V-699ZM507-535.5 449.5-592v-294.5q0-11.675 8.425-20.088 8.426-8.412 20.5-8.412 12.075 0 20.325 8.412Q507-898.175 507-886.5v351ZM671.5-483H614v-363.668q0-11.582 8.425-19.957 8.426-8.375 20.5-8.375 12.075 0 20.325 8.351t8.25 19.935V-483ZM751-126.5 342.5-535v250.5L165-415l199 291q7 11 17.839 16.75 10.84 5.75 24.161 5.75h280.5q17.897 0 34.948-6.25Q738.5-114 751-126.5ZM406-44q-27.049 0-51.274-12.5Q330.5-69 316-91.5L59.5-468l19-15.5q17-14.5 39-18.25t43.573 12.938L285-394.5v-198l-253-253L73.5-887 873-87l-41 41-41-40.5q-20 19.5-47.17 31T686.5-44H406Zm140.5-287.5ZM560-483Z"/></svg>

After

Width:  |  Height:  |  Size: 911 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m480-84.5-237-237 42.5-42L480-169l195-194.5 42 42-237 237Zm-195-512-42-42 237-237 237 237-42 42L480-791 285-596.5Z"/></svg>

After

Width:  |  Height:  |  Size: 220 B

View File

@ -1,5 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.foundation.contextMenuOpenDetector
import androidx.compose.runtime.Composable
import androidx.compose.ui.*
import androidx.compose.ui.graphics.painter.Painter
@ -29,3 +30,5 @@ onExternalDrag(enabled) {
is DragData.Text -> onText(data.readText())
}
}
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() }

View File

@ -6,9 +6,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import chat.simplex.common.platform.VideoPlayer
import chat.simplex.common.platform.isPlaying
import chat.simplex.common.views.helpers.onRightClick
import chat.simplex.common.platform.*
@Composable
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {

View File

@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
object NoIndication : Indication {

View File

@ -1,44 +0,0 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.DropdownMenu
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
actual fun Modifier.onRightClick(action: () -> Unit): Modifier = contextMenuOpenDetector { action() }
actual interface DefaultExposedDropdownMenuBoxScope {
@Composable
actual fun DefaultExposedDropdownMenu(
expanded: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier,
content: @Composable ColumnScope.() -> Unit
) {
DropdownMenu(expanded, onDismissRequest, offset = DpOffset(0.dp, (-40).dp)) {
Column {
content()
}
}
}
}
@Composable
actual fun DefaultExposedDropdownMenuBox(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier,
content: @Composable DefaultExposedDropdownMenuBoxScope.() -> Unit
) {
val obj = remember { object : DefaultExposedDropdownMenuBoxScope {} }
Box(Modifier
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { onExpandedChange(!expanded) })
) {
obj.content()
}
}

View File

@ -88,7 +88,7 @@ compose {
notarization {
this.appleID.set(appleId)
this.password.set(password)
this.ascProvider.set(teamId)
this.teamID.set(teamId)
}
}
}

View File

@ -33,4 +33,4 @@ desktop.version_code=15
kotlin.version=1.8.20
gradle.plugin.version=7.4.2
compose.version=1.4.3
compose.version=1.5.10

View File

@ -62,7 +62,8 @@ broadcastBot BroadcastBotOpts {publishers, welcomeMessage, prohibitedMessage} _u
MCLink {} -> True
MCImage {} -> True
_ -> False
broadcastTo ct'@Contact {activeConn = conn@Connection {connStatus}} =
broadcastTo Contact {activeConn = Nothing} = False
broadcastTo ct'@Contact {activeConn = Just conn@Connection {connStatus}} =
(connStatus == ConnSndReady || connStatus == ConnReady)
&& not (connDisabled conn)
&& contactId' ct' /= contactId' ct

View File

@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 3a2969c44ad2f7244188fd98ad0df504ead89d14
tag: f3045563edf58a72b515f79400bb67d2d1bfed84
source-repository-package
type: git
@ -19,7 +19,7 @@ source-repository-package
source-repository-package
type: git
location: https://github.com/kazu-yamamoto/http2.git
tag: 804fa283f067bd3fd89b8c5f8d25b3047813a517
tag: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb
source-repository-package
type: git

View File

@ -0,0 +1,124 @@
# Groups integrity
## Problems
- Inconsistency of group state:
- group profile including group wide preferences,
- list of members and their roles.
- Lack of group messages integrity - group member can send different messages to different members.
Lack of group consistency leads to group federation both in terms of members list and content visible to different members, which leads to user frustration and lack of trust.
Improvements to group design should provide:
- Consistent group state.
- Group messages integrity:
- integrity violations (different message sent to different members) should be identified and shown to users,
- missed messages should be requested to fill in gaps.
## Design ideas and questions
### Group messages integrity
A message container to include member's message ID (ordered?), and list of IDs and hashes of parent messages.
```haskell
data MsgParentId = MsgParentId
{ memberId :: MemberId,
msgId :: Int64, -- sequential message ID for parent message (among memberId member messages)
msgHash :: ByteString
}
data MsgIds = MsgIds
{ msgId :: Int64, -- sequential message ID for member's message
parentIds :: [MsgParentId]
}
```
Questions:
- What level of protocol should include MsgIds, and what messages should be included into integrity graph?
- Having it on AppMessage level would allow to include all protocol messages. But some protocol messages are sent with different content per member (XGrpMemIntro, XGrpMemFwd, probe messages) and would have different hash. Also they contain sensitive data such as invitation links and should not be forwarded anyway.
- If MsgIds is MsgContainer level, only XMsgNew would have it. This excludes other content messages such as updates, deletes, etc.
- Include it into specific "content" chat events - XMsgNew, XMsgFileCancel (unused), XMsgUpdate, XMsgDel, XMsgReact, XFile (not used anymore but was never fully deprecated), XFileCancel.
- Some new protocol level container, uniting above events?
- Should msgId be sequential integer? (It leaks metadata about member's previous activity in the group) Can SharedMsgId be used instead?
- Depending on number of parent messages, parentIds can become arbitrarily long and not fit into 16KB block, especially for messages containing profiles pictures.
When receiving a message with unknown parent identifiers, client should request missing messages from the sender by sending XGrpRequestSkipped, including last seen message reference for each missing parent. When receiving XGrpRequestSkipped, member should forward requested messages up to last seen parent using XGrpRequested.
```haskell
-- include received parentId?
XGrpRequestSkipped :: [MsgParentId] -> ChatMsgEvent 'Json
data MsgRequestedParent = MsgRequested
{ parentId :: MsgParentId,
msg :: MsgContainer -- content TBD based on scope of messages included into integrity graph. Full event?
}
XGrpRequested :: MsgRequestedParent -> ChatMsgEvent 'Json
```
Questions:
- Depending on number of missing parents, XGrpRequestSkipped may not fit into 16KB block.
- There may be multiple skipped messages for a given member, should they be sent sequentially from oldest (following the one known to requesting member) to newest?
- XGrpRequested may not fit into 16KB block even if original MsgContainer / chat event did fit. On the other hand multiple XGrpRequested messages can be batched.
- Malicious group member may arbitrarily request (at any time or in response to a new message) any number of skipped messages by sending parentIds from the past and trigger receiving member to send a lot of traffic. There are already some automatic response events in protocol, but they are harder to abuse: XGrpMemFwd - requires cooperation with other member, or creating connection; receipts - can be turned off; probes - requires member having matching contact and being non incognito in group. Should the member receiving XGrpRequestSkipped protect from such abuse by limiting number of requested messages? Limiting number or requests from a specific member in time?
- By the time member requests skipped messages, sender may be offline. Should the requester send XGrpRequestSkipped to other members?
- together with the request to sender or after some period?
- to which members? - fraction of admins? all admins?
- Member receiving XGrpRequestSkipped may not have requested messages, for example:
- request is for the older parent id, and member never received it himself (was not part of the group then or has gap in place of this message), or has gap between sent message parent and requested parent.
- member deleted parent(s), e.g. via periodic cleanup, or by deleting specific messages.
- don't fully delete group message records while in group? instead only overwrite content?
Message integrity is computed for received messages, can be updated on receiving requested message parents.
```haskell
data GroupMsgIntegrity
= GMIOk
| GMISkippedParents {skippedParents :: [MsgParentId]}
| GMIBadParentHash {knownParent :: MsgParentId, badParent :: MsgParentId} -- list?
```
```sql
CREATE TABLE message_integrity_records( -- message_hashes? group_messages?
message_integrity_record_id INTEGER PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- SET NULL?
group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE,
group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE,
member_id BLOB NOT NULL,
member_msg_id INTEGER NOT NULL, -- shared_msg_id?
msg_hash BLOB NOT NULL,
msg_integrity TEXT NOT NULL, -- computed for received messages, for sent always Ok?
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
-- many to many table for message_integrity_records table
-- (parent can have multiple children, child can have multiple parents)
-- parent can be null if it wasn't received
CREATE TABLE message_parents(
message_parent_id INTEGER PRIMARY KEY,
message_integrity_record_id INTEGER NOT NULL REFERENCES message_integrity_record_id ON DELETE CASCADE,
message_parent_integrity_record_id INTEGER REFERENCES message_integrity_record_id ON DELETE CASCADE,
msg_parent_member_id BLOB NOT NULL,
msg_parent_member_msg_id INTEGER NOT NULL,
msg_hash BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
);
```
How should message integrity errors be displayed in UI?
- Displaying skipped parent errors would clutter UI due to delays in delivery. Probably they shouldn't be displayed.
- Integrity violations (hashes not matching) should be displayed on respective chat items.
- if integrity is on AppMessage level for all chat events - not all messages have corresponding chat items, create internal chat items?
- if it's on the level of content messages, updates / etc. can be high above in message history, deletes can be not visible at all (full delete).
- how to get reference to message via chat item when loading chat items? Integrity violation can be on a message different than chat item's created_by_msg_id message. For each chat item load integrity of all messages via chat_item_messages?
- If integrity errors are only displayed on integrity violations, for malicious member to work around it and send different message to different group members could he specify unknown (far into future or past) message id, instead of incorrect one? Sender then wouldn't respond with skipped parents (and other members wouldn't be able to) - how to differentiate between this case and skipper parent error that is to be ignored in UI?
- Should it be prohibited to not send MsgIds (to avoid message integrity check) if member protocol version supports it? Should it be prohibited at all and group with integrity be separated? How to distinguish between messages sent without integrity fields and messages with skipped parents in UI?
- Not showing skipped parents integrity error in UI would lead user to believe integrity is preserved, and integrity violation can be revealed later. If conversation is time sensitive member may react to message considering it conversation integrity wasn't breached, and integrity violation may be revealed later. Having eventual integrity may not be better than having no integrity at all, and may even be worse because it produces false assumptions regarding conversation integrity. The goal can be narrowed to only restoring missed messages (gaps), without calculating integrity.
### Consistent group state
TODO

View File

@ -0,0 +1,229 @@
# Group integrity
3 level of DAGs:
Owner
- group profile and permissions, admin invites and removals
- in case of gap vote before applying event
Admin
- member invites and removals
- prohibit to add and remove admins
- in case of gap most destructive wins
- link to owner dag
Messages
- in case of gap show history according to local graph, correct when owner or admin dag changes
- link to both admin and owner dags
```haskell
-- protocol
data MsgParent = MsgParent
{ memberId :: MemberId,
memberName :: String, -- recipient can use to display message if they don't have member introduced;
-- optional?
sharedMsgId :: SharedMsgId,
msgHash :: ByteString,
msgBody :: String? -- recipient can use to display message in case parent wasn't yet received;
-- sender can pack as many parents as fits into block
stored :: Bool -- whether sender has message stored, and it can be requested
}
data MsgIds = MsgIds -- include into chat event
{ sharedMsgId :: SharedMsgId,
ownerDAGMsgId :: SharedMsgId, -- list of parents?
adminDAGMsgId :: SharedMsgId,
parents :: [MsgParent]
}
-- model
data OwnerDAGEventParent
= ODEPKnown {eventId :: ?} -- DB id? sharedMsgId?
| ODEPUnknown {eventId :: ?}
data OwnerDAGEvent = DAGEvent
{ eventId :: ?,
parents :: [OwnerDAGEventParent]
}
data AdminDAGEventParent
= ADEPKnown {eventId :: ?}
| ADEPUnknown {eventId :: ?}
data AdminDAGEvent = DAGEvent
{ eventId :: ?,
ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ?
parents :: [AdminDAGEventParent]
}
data MessagesDAGEventParent
= MDEPKnown {eventId :: ?}
| MDEPUnknown {eventId :: ?}
data MessagesDAGEvent = DAGEvent
{ eventId :: ?,
ownerDAGEventId :: ?, -- [OwnerDAGEventParent] - parentIds? ?
adminDAGEventId :: ?, -- [AdminDAGEventParent] - parentIds? ?
parents :: [MessagesDAGEventParent]
}
```
How to restore from destructive messages?
Even if all message parents are known, destructive logic of message should be applied after other members refer it.
How to workaround members maliciously referring non-existent parents?
For example, this can lead to an owner preventing group updates.
```
-- should dag be maintained in memory? older events to be removed
-- read on event?
-- how long into past to get dag?
ClassifiedEvent = OwnerEvent | AdminEvent | MsgEvent
def processEvent(e: Event) =
classifiedEvent <- classifyEvent(e)
case classifiedEvent of
OwnerEvent oe -> processOwnerEvent(oe)
AdminEvent ae -> processAdminEvent(ae)
MsgEvent me -> processMsgEvent(me)
def classifyEvent(e: Event) -> ClassifiedEvent? =
case e of
XMsgNew -> MsgEvent
XMsgFileDescr -> Nothing -- different per member
XMsgFileCancel -> MsgEvent
XMsgUpdate -> MsgEvent
XMsgDel -> MsgEvent
XMsgReact -> MsgEvent
XFile -> MsgEvent
XFileCancel -> MsgEvent
XFileAcptInv -> Nothing -- different per member
XGrpMemNew -> OwnerEvent -- sent by owner, new member is admin or owner
or AdminEvent -- sent by admin (or by owner and new member role is less than admin?)
-- problem: if member role changes, members can add event to different dags
-- what should define member role?
XGrpMemIntro -> Nothing -- received only by invitee
XGrpMemInv -> Nothing -- received only by host
XGrpMemFwd -> Nothing -- different per member; not received by invitee
XGrpMemRole -> OwnerEvent -- sent by owner about owner or admin
or AdminEvent -- sent by admin (or by owner about member with role less than admin?)
XGrpMemDel -> OwnerEvent -- sent by owner about owner or admin
or AdminEvent -- sent by admin (or by owner about member with role less than admin?)
XGrpLeave -> MsgEvent
XGrpDel -> OwnerEvent
XGrpInfo -> OwnerEvent
XGrpDirectInv -> Nothing -- received by single member
XInfoProbe -> Nothing -- per member
XInfoProbeCheck -> Nothing -- per member
XInfoProbeOk -> Nothing -- per member
BFileChunk -> Nothing -- could be MsgEvent?
_ -> Nothing -- not supported in groups
-- # owner events
def processOwnerEvent(oe: OwnerEvent) =
process every owner event after owners reach consensus
// def processOwnerEvent(oe: OwnerEvent) =
// addOwnerDagEvent(oe)
// applyOwnerDagEvent(oe)
//
// def addOwnerDagEvent(oe: OwnerEvent) =
// if (any parent of oe not in dag):
// buffer until all parents are in ownerDag
// else
// add oe to ownerDag
//
// def applyOwnerDagEvent(oe: OwnerEvent) =
// case oe of
// -- process XGrpMemNew, XGrpMemRole, XGrpMemDel same as for admin dag (see below), or should vote for all events?
// XGrpMemNew -> ...
// XGrpMemRole -> ...
// XGrpMemDel -> ...
// -- how to vote - to depend on action (group - manual, update - automatic?);
// -- wait for voting always, or if event has unknown parents? (gaps in dag)
// -- how to treat delayed integrity violation - owner sending message to select members
// XGrpDel ->
// -- create "pending group deletion", wait for confirmation from majority of owners?
// -- new protocol requiring user action from other owners?
// XGrpInfo ->
// -- create "unconfirmed group profile update", remember prev group profile
// -- remove from "unconfirmed group profile update" when this event is in dag and not a leaf?
// -- if another group profile update event is received, revert "unconfirmed" event, don't apply new
// -- so if more than one update is received while dag is not merged to single vertice, all updates are not applied
// -- - this would likely lock out owners from any future updates
// -- - merge to new starting point after some time passes?
// -- - mark parents that are never received and so always block graph merging as special type?
-- # admin events
def processAdminEvent(ae: AdminEvent) =
lookup in owner dag - does member still have permission?
addAdminDagEvent(ae)
applyAdminDagEvent(ae)
def addAdminDagEvent(ae: AdminEvent) =
if (any parent of ae not in dag):
buffer until all parents are in adminDag
else
add ae to adminDag
def applyAdminDagEvent(ae: AdminEvent) =
case ae of
XGrpMemNew ->
-- handles case where messages from 2 admins about member addition and deletion arrive out of order
if member is not in "unconfirmed member deletions":
add member
XGrpMemRole ->
add role change to "unconfirmed role change"
-- remove from "unconfirmed role change" when this event is in dag and not a leaf?
if another role change already in "unconfirmed role change":
if new role is less than role in "unconfirmed role change":
change role -- role change applies in direction of lower role
XGrpMemDel ->
add member to "unconfirmed member deletions"
-- remove from "unconfirmed member deletions" when this event is in dag and not a leaf?
if member found by memberId:
delete member
-- ^ problem: if later admin event turns out to fail integrity check, how to revert it?
-- member deletion: don't apply until in graph and not a leaf
-- role change: remember previous role and revert
-- member addition: delete member
-- # message events
def processMsgEvent(me: MsgEvent) =
lookup points in owner and admin dag?
- does member have permission to send event? (role changed/removed)
addMsgDagEvent(me)
applyMsgEvent(me)
def addMsgDagEvent(me: MsgEvent) =
for me.parents not in msgDag:
add MDEPUnknown parent to msgDag
add me to msgDag
def applyMsgEvent(me: MsgEvent) =
case me of
XMsgNew -> message to view
-- start process waiting for missing parents; if parents are not received:
-- can be shown as integrity violation if parents are not received
-- can be shown as integrity violation if other members don't refer it?
XMsgFileCancel -> cancel file immediately
-- wait for missing parents / referrals similarly to XMsgNew
-- restart file reception on integrity violation?
XMsgUpdate -> update to view -- same as XMsgNew
XMsgDel -> mark deleted, don't apply full delete until parents/referrals are received?
XMsgReact -> to view -- same as XMsgNew
XFile -> -- deprecate?
XFileCancel -> cancel -- same as XMsgFileCancel
XGrpLeave -> mark member as left, don't delete member connection immediately
-- member may try to maliciously remove connections selectively
-- wait for integrity check
```
# Admin blockchain
Suppose admin DAG is replaced with blockchain, with a conflict resolution protocol to provide consistency of membership changes. Take Simplex (not to confuse with SimpleX chat) protocol (https://simplex.blog/). To reach BFT consensus and make progress, 2n/3 votes on block proposals are required, and it's assumed `f < n/3` where f is number of malicious actors. In a highly asynchronous setting of decentralized groups operated by mobile devices, progress seems unlikely or very slow. Should "admin participation" be hosted?

View File

@ -95,6 +95,11 @@ if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsmplxmq*.$LIB_EXT
fi
LIBCRYPTO_PATH=$(otool -l libHSsqlcphr-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT libHSsqlcphr-*.$LIB_EXT
fi
for lib in $(find . -type f -name "*.$LIB_EXT"); do
RPATHS=`otool -l $lib | grep -E "path /Users/|path /usr/local|path /opt/" | cut -d' ' -f11`
for RPATH in $RPATHS; do

View File

@ -1,7 +1,7 @@
{
"https://github.com/simplex-chat/simplexmq.git"."3a2969c44ad2f7244188fd98ad0df504ead89d14" = "12kdb016bcfgsjpypyr3x0rnf2w4jgyawwygjl5vjsdsa8rm1mqv";
"https://github.com/simplex-chat/simplexmq.git"."f3045563edf58a72b515f79400bb67d2d1bfed84" = "0v6szfjy8a6vdbrwbp6vq382mi2zwly54f63wghsvqqvbhjd52bl";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895";
"https://github.com/kazu-yamamoto/http2.git"."f5525b755ff2418e6e6ecc69e877363b0d0bcaeb" = "0fyx0047gvhm99ilp212mmz37j84cwrfnpmssib5dw363fyb88b6";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";
"https://github.com/simplex-chat/sqlcipher-simple.git"."5e154a2aeccc33ead6c243ec07195ab673137221" = "1d1gc5wax4vqg0801ajsmx1sbwvd9y7p7b8mmskvqsmpbwgbh0m0";
"https://github.com/simplex-chat/aeson.git"."aab7b5a14d6c5ea64c64dcaee418de1bb00dcc2b" = "0jz7kda8gai893vyvj96fy962ncv8dcsx71fbddyy8zrvc88jfrr";

View File

@ -610,8 +610,8 @@ processChatCommand = \case
let fileName = takeFileName file
fileInvitation = FileInvitation {fileName, fileSize, fileDigest = Nothing, fileConnReq, fileInline, fileDescr = Nothing}
chSize <- asks $ fileChunkSize . config
withStore' $ \db -> do
ft@FileTransferMeta {fileId} <- createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode
withStore $ \db -> do
ft@FileTransferMeta {fileId} <- liftIO $ createSndDirectFileTransfer db userId ct file fileInvitation agentConnId_ chSize subMode
fileStatus <- case fileInline of
Just IFMSent -> createSndDirectInlineFT db ct ft $> CIFSSndTransfer 0 1
_ -> pure CIFSSndStored
@ -743,7 +743,8 @@ processChatCommand = \case
let fileSource = Just $ CryptoFile filePath cfArgs
ciFile = CIFile {fileId, fileName, fileSize, fileSource, fileStatus = CIFSSndStored, fileProtocol = FPXFTP}
case contactOrGroup of
CGContact Contact {activeConn} -> withStore' $ \db -> createSndFTDescrXFTP db user Nothing activeConn ft fileDescr
CGContact Contact {activeConn} -> forM_ activeConn $ \conn ->
withStore' $ \db -> createSndFTDescrXFTP db user Nothing conn ft fileDescr
CGGroup (Group _ ms) -> forM_ ms $ \m -> saveMemberFD m `catchChatError` (toView . CRChatError (Just user))
where
-- we are not sending files to pending members, same as with inline files
@ -1182,7 +1183,8 @@ processChatCommand = \case
ct <- getContact db user chatId
liftIO $ updateContactSettings db user chatId chatSettings
pure ct
withAgent $ \a -> toggleConnectionNtfs a (contactConnId ct) (chatHasNtfs chatSettings)
forM_ (contactConnId ct) $ \connId ->
withAgent $ \a -> toggleConnectionNtfs a connId (chatHasNtfs chatSettings)
ok user
CTGroup -> do
ms <- withStore $ \db -> do
@ -1203,9 +1205,12 @@ processChatCommand = \case
ok user
APIContactInfo contactId -> withUser $ \user@User {userId} -> do
-- [incognito] print user's incognito profile for this contact
ct@Contact {activeConn = Connection {customUserProfileId}} <- withStore $ \db -> getContact db user contactId
incognitoProfile <- forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
connectionStats <- withAgent (`getConnectionServers` contactConnId ct)
ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId
incognitoProfile <- case activeConn of
Nothing -> pure Nothing
Just Connection {customUserProfileId} ->
forM customUserProfileId $ \profileId -> withStore (\db -> getProfileById db userId profileId)
connectionStats <- mapM (withAgent . flip getConnectionServers) (contactConnId ct)
pure $ CRContactInfo user ct connectionStats (fmap fromLocalProfile incognitoProfile)
APIGroupInfo gId -> withUser $ \user -> do
(g, s) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> liftIO (getGroupSummary db user gId)
@ -1216,8 +1221,11 @@ processChatCommand = \case
pure $ CRGroupMemberInfo user g m connectionStats
APISwitchContact contactId -> withUser $ \user -> do
ct <- withStore $ \db -> getContact db user contactId
connectionStats <- withAgent $ \a -> switchConnectionAsync a "" $ contactConnId ct
pure $ CRContactSwitchStarted user ct connectionStats
case contactConnId ct of
Just connId -> do
connectionStats <- withAgent $ \a -> switchConnectionAsync a "" connId
pure $ CRContactSwitchStarted user ct connectionStats
Nothing -> throwChatError $ CEContactNotActive ct
APISwitchGroupMember gId gMemberId -> withUser $ \user -> do
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
case memberConnId m of
@ -1227,8 +1235,11 @@ processChatCommand = \case
_ -> throwChatError CEGroupMemberNotActive
APIAbortSwitchContact contactId -> withUser $ \user -> do
ct <- withStore $ \db -> getContact db user contactId
connectionStats <- withAgent $ \a -> abortConnectionSwitch a $ contactConnId ct
pure $ CRContactSwitchAborted user ct connectionStats
case contactConnId ct of
Just connId -> do
connectionStats <- withAgent $ \a -> abortConnectionSwitch a connId
pure $ CRContactSwitchAborted user ct connectionStats
Nothing -> throwChatError $ CEContactNotActive ct
APIAbortSwitchGroupMember gId gMemberId -> withUser $ \user -> do
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
case memberConnId m of
@ -1238,9 +1249,12 @@ processChatCommand = \case
_ -> throwChatError CEGroupMemberNotActive
APISyncContactRatchet contactId force -> withUser $ \user -> do
ct <- withStore $ \db -> getContact db user contactId
cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a (contactConnId ct) force
createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing
pure $ CRContactRatchetSyncStarted user ct cStats
case contactConnId ct of
Just connId -> do
cStats@ConnectionStats {ratchetSyncState = rss} <- withAgent $ \a -> synchronizeRatchet a connId force
createInternalChatItem user (CDDirectSnd ct) (CISndConnEvent $ SCERatchetSync rss Nothing) Nothing
pure $ CRContactRatchetSyncStarted user ct cStats
Nothing -> throwChatError $ CEContactNotActive ct
APISyncGroupMemberRatchet gId gMemberId force -> withUser $ \user -> do
(g, m) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
case memberConnId m of
@ -1250,16 +1264,19 @@ processChatCommand = \case
pure $ CRGroupMemberRatchetSyncStarted user g m cStats
_ -> throwChatError CEGroupMemberNotActive
APIGetContactCode contactId -> withUser $ \user -> do
ct@Contact {activeConn = conn@Connection {connId}} <- withStore $ \db -> getContact db user contactId
code <- getConnectionCode (contactConnId ct)
ct' <- case contactSecurityCode ct of
Just SecurityCode {securityCode}
| sameVerificationCode code securityCode -> pure ct
| otherwise -> do
withStore' $ \db -> setConnectionVerified db user connId Nothing
pure (ct :: Contact) {activeConn = conn {connectionCode = Nothing}}
_ -> pure ct
pure $ CRContactCode user ct' code
ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId
case activeConn of
Just conn@Connection {connId} -> do
code <- getConnectionCode $ aConnId conn
ct' <- case contactSecurityCode ct of
Just SecurityCode {securityCode}
| sameVerificationCode code securityCode -> pure ct
| otherwise -> do
withStore' $ \db -> setConnectionVerified db user connId Nothing
pure (ct :: Contact) {activeConn = Just $ (conn :: Connection) {connectionCode = Nothing}}
_ -> pure ct
pure $ CRContactCode user ct' code
Nothing -> throwChatError $ CEContactNotActive ct
APIGetGroupMemberCode gId gMemberId -> withUser $ \user -> do
(g, m@GroupMember {activeConn}) <- withStore $ \db -> (,) <$> getGroupInfo db user gId <*> getGroupMember db user gId gMemberId
case activeConn of
@ -1275,17 +1292,22 @@ processChatCommand = \case
pure $ CRGroupMemberCode user g m' code
_ -> throwChatError CEGroupMemberNotActive
APIVerifyContact contactId code -> withUser $ \user -> do
Contact {activeConn} <- withStore $ \db -> getContact db user contactId
verifyConnectionCode user activeConn code
ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId
case activeConn of
Just conn -> verifyConnectionCode user conn code
Nothing -> throwChatError $ CEContactNotActive ct
APIVerifyGroupMember gId gMemberId code -> withUser $ \user -> do
GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId
case activeConn of
Just conn -> verifyConnectionCode user conn code
_ -> throwChatError CEGroupMemberNotActive
APIEnableContact contactId -> withUser $ \user -> do
Contact {activeConn} <- withStore $ \db -> getContact db user contactId
withStore' $ \db -> setConnectionAuthErrCounter db user activeConn 0
ok user
ct@Contact {activeConn} <- withStore $ \db -> getContact db user contactId
case activeConn of
Just conn -> do
withStore' $ \db -> setConnectionAuthErrCounter db user conn 0
ok user
Nothing -> throwChatError $ CEContactNotActive ct
APIEnableGroupMember gId gMemberId -> withUser $ \user -> do
GroupMember {activeConn} <- withStore $ \db -> getGroupMember db user gId gMemberId
case activeConn of
@ -1546,16 +1568,19 @@ processChatCommand = \case
inv@ReceivedGroupInvitation {fromMember} <- getGroupInvitation db user groupId
(inv,) <$> getContactViaMember db user fromMember
let ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} = invitation
Contact {activeConn = Connection {peerChatVRange}} = ct
subMode <- chatReadVar subscriptionMode
dm <- directMessage $ XGrpAcpt (memberId (membership :: GroupMember))
agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode
withStore' $ \db -> do
createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode
updateGroupMemberStatus db userId fromMember GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
updateCIGroupInvitationStatus user
pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing
Contact {activeConn} = ct
case activeConn of
Just Connection {peerChatVRange} -> do
subMode <- chatReadVar subscriptionMode
dm <- directMessage $ XGrpAcpt (memberId (membership :: GroupMember))
agentConnId <- withAgent $ \a -> joinConnection a (aUserId user) True connRequest dm subMode
withStore' $ \db -> do
createMemberConnection db userId fromMember agentConnId (fromJVersionRange peerChatVRange) subMode
updateGroupMemberStatus db userId fromMember GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
updateCIGroupInvitationStatus user
pure $ CRUserAcceptedGroupSent user g {membership = membership {memberStatus = GSMemAccepted}} Nothing
Nothing -> throwChatError $ CEContactNotActive ct
where
updateCIGroupInvitationStatus user = do
AChatItem _ _ cInfo ChatItem {content, meta = CIMeta {itemId}} <- withStore $ \db -> getChatItemByGroupId db user groupId
@ -2056,7 +2081,8 @@ processChatCommand = \case
void $ sendDirectContactMessage ct' (XInfo mergedProfile')
when (directOrUsed ct') $ createSndFeatureItems user' ct ct'
updateContactPrefs :: User -> Contact -> Preferences -> m ChatResponse
updateContactPrefs user@User {userId} ct@Contact {activeConn = Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
updateContactPrefs _ ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotActive ct
updateContactPrefs user@User {userId} ct@Contact {activeConn = Just Connection {customUserProfileId}, userPreferences = contactUserPrefs} contactUserPrefs'
| contactUserPrefs == contactUserPrefs' = pure $ CRContactPrefsUpdated user ct ct
| otherwise = do
assertDirectAllowed user MDSnd ct XInfo_
@ -2589,8 +2615,8 @@ acceptContactRequestAsync user UserContactRequest {agentInvitationId = AgentInvI
let profileToSend = profileToSendOnAccept user incognitoProfile
(cmdId, acId) <- agentAcceptContactAsync user True invId (XInfo profileToSend) subMode
withStore' $ \db -> do
ct@Contact {activeConn = Connection {connId}} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode
setCommandConnId db user cmdId connId
ct@Contact {activeConn} <- createAcceptedContact db user acId (fromJVersionRange cReqChatVRange) cName profileId p userContactLinkId xContactId incognitoProfile subMode
forM_ activeConn $ \Connection {connId} -> setCommandConnId db user cmdId connId
pure ct
acceptGroupJoinRequestAsync :: ChatMonad m => User -> GroupInfo -> UserContactRequest -> GroupMemberRole -> Maybe IncognitoProfile -> m GroupMember
@ -2711,7 +2737,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
getContactConns :: m ([ConnId], Map ConnId Contact)
getContactConns = do
cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts
let connIds = map contactConnId (filter contactActive cts)
let connIds = catMaybes $ map contactConnId (filter contactActive cts)
pure (connIds, M.fromList $ zip connIds cts)
getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact)
getUserContactLinkConns = do
@ -2752,9 +2778,10 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
toView $ CRNetworkStatuses (Just user) $ map (uncurry ConnNetworkStatus) statuses
where
addStatus :: ConnId -> Contact -> [(AgentConnId, NetworkStatus)] -> [(AgentConnId, NetworkStatus)]
addStatus connId ct =
let ns = (contactAgentConnId ct, netStatus $ resultErr connId rs)
in (ns :)
addStatus _ Contact {activeConn = Nothing} nss = nss
addStatus connId Contact {activeConn = Just Connection {agentConnId}} nss =
let ns = (agentConnId, netStatus $ resultErr connId rs)
in ns : nss
netStatus :: Maybe ChatError -> NetworkStatus
netStatus = maybe NSConnected $ NSError . errorNetworkStatus
errorNetworkStatus :: ChatError -> String
@ -3197,7 +3224,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
cmdId <- createAckCmd conn
withAckMessage agentConnId cmdId msgMeta $ do
(conn', msg@RcvMessage {chatMsgEvent = ACME _ event}) <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody cmdId
let ct' = ct {activeConn = conn'} :: Contact
let ct' = ct {activeConn = Just conn'} :: Contact
assertDirectAllowed user MDRcv ct' $ toCMEventTag event
updateChatLock "directMessage" event
case event of
@ -3305,7 +3332,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
(RSAllowed, _, Just cryptoErr) -> processErr cryptoErr
(RSAgreed, Just _, _) -> do
withStore' $ \db -> setConnectionVerified db user connId Nothing
let ct' = ct {activeConn = conn {connectionCode = Nothing}} :: Contact
let ct' = ct {activeConn = Just $ (conn :: Connection) {connectionCode = Nothing}} :: Contact
ratchetSyncEventItem ct'
securityCodeChanged ct'
_ -> ratchetSyncEventItem ct
@ -3458,11 +3485,12 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
notifyMemberConnected gInfo m Nothing
let connectedIncognito = memberIncognito membership
when (memberCategory m == GCPreMember) $ probeMatchingMemberContact m connectedIncognito
Just ct@Contact {activeConn = Connection {connStatus}} ->
when (connStatus == ConnReady) $ do
notifyMemberConnected gInfo m $ Just ct
let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo
when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True
Just ct@Contact {activeConn} ->
forM_ activeConn $ \Connection {connStatus} ->
when (connStatus == ConnReady) $ do
notifyMemberConnected gInfo m $ Just ct
let connectedIncognito = contactConnIncognito ct || incognitoMembership gInfo
when (memberCategory m == GCPreMember) $ probeMatchingContactsAndMembers ct connectedIncognito True
MSG msgMeta _msgFlags msgBody -> do
cmdId <- createAckCmd conn
withAckMessage agentConnId cmdId msgMeta $ do
@ -4272,7 +4300,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
_ -> do
event <- withStore $ \db -> do
ci' <- updateDirectCIFileStatus db user fileId $ CIFSSndTransfer 0 1
sft <- liftIO $ createSndDirectInlineFT db ct ft
sft <- createSndDirectInlineFT db ct ft
pure $ CRSndFileStart user ci' sft
toView event
ifM
@ -4388,30 +4416,31 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> m ()
processGroupInvitation ct inv msg msgMeta = do
let Contact {localDisplayName = c, activeConn = Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'}} = ct
let Contact {localDisplayName = c, activeConn} = ct
GroupInvitation {fromMember = (MemberIdRole fromMemId fromRole), invitedMember = (MemberIdRole memId memRole), connRequest, groupLinkId} = inv
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
-- [incognito] if direct connection with host is incognito, create membership using the same incognito profile
(gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId
if sameGroupLinkId groupLinkId groupLinkId'
then do
subMode <- chatReadVar subscriptionMode
dm <- directMessage $ XGrpAcpt memberId
connIds <- joinAgentConnectionAsync user True connRequest dm subMode
withStore' $ \db -> do
setViaGroupLinkHash db groupId connId
createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode
updateGroupMemberStatusById db userId hostId GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct)
else do
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
forM_ activeConn $ \Connection {connId, peerChatVRange, customUserProfileId, groupLinkId = groupLinkId'} -> do
checkIntegrityCreateItem (CDDirectRcv ct) msgMeta
when (fromRole < GRAdmin || fromRole < memRole) $ throwChatError (CEGroupContactRole c)
when (fromMemId == memId) $ throwChatError CEGroupDuplicateMemberId
-- [incognito] if direct connection with host is incognito, create membership using the same incognito profile
(gInfo@GroupInfo {groupId, localDisplayName, groupProfile, membership = membership@GroupMember {groupMemberId, memberId}}, hostId) <- withStore $ \db -> createGroupInvitation db user ct inv customUserProfileId
if sameGroupLinkId groupLinkId groupLinkId'
then do
subMode <- chatReadVar subscriptionMode
dm <- directMessage $ XGrpAcpt memberId
connIds <- joinAgentConnectionAsync user True connRequest dm subMode
withStore' $ \db -> do
setViaGroupLinkHash db groupId connId
createMemberConnectionAsync db user hostId connIds (fromJVersionRange peerChatVRange) subMode
updateGroupMemberStatusById db userId hostId GSMemAccepted
updateGroupMemberStatus db userId membership GSMemAccepted
toView $ CRUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct)
else do
let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta content
withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci)
toView $ CRReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole}
where
sameGroupLinkId :: Maybe GroupLinkId -> Maybe GroupLinkId -> Bool
sameGroupLinkId (Just gli) (Just gli') = gli == gli'
@ -4434,7 +4463,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
contactConns <- withStore $ \db -> getContactConnections db userId ct'
deleteAgentConnectionsAsync user $ map aConnId contactConns
forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted
let ct'' = ct' {activeConn = (contactConn ct') {connStatus = ConnDeleted}} :: Contact
activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted}
let ct'' = ct' {activeConn = activeConn'} :: Contact
ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci)
toView $ CRContactDeletedByContact user ct''
@ -4944,20 +4974,21 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
Nothing -> createNewContact subMode
Just mContactId -> do
mCt <- withStore $ \db -> getContact db user mContactId
let Contact {activeConn = Connection {connId}, contactGrpInvSent} = mCt
if contactGrpInvSent
then do
ownConnReq <- withStore $ \db -> getConnReqInv db connId
-- in case both members sent x.grp.direct.inv before receiving other's for processing,
-- only the one who received greater connReq joins, the other creates items and waits for confirmation
if strEncode connReq > strEncode ownConnReq
then joinExistingContact subMode mCt
else createItems mCt m
else joinExistingContact subMode mCt
let Contact {activeConn, contactGrpInvSent} = mCt
forM_ activeConn $ \Connection {connId} ->
if contactGrpInvSent
then do
ownConnReq <- withStore $ \db -> getConnReqInv db connId
-- in case both members sent x.grp.direct.inv before receiving other's for processing,
-- only the one who received greater connReq joins, the other creates items and waits for confirmation
if strEncode connReq > strEncode ownConnReq
then joinExistingContact subMode mCt
else createItems mCt m
else joinExistingContact subMode mCt
where
joinExistingContact subMode mCt = do
connIds <- joinConn subMode
mCt' <- withStore' $ \db -> updateMemberContactInvited db user connIds g mConn mCt subMode
mCt' <- withStore $ \db -> updateMemberContactInvited db user connIds g mConn mCt subMode
createItems mCt' m
securityCodeChanged mCt'
createNewContact subMode = do
@ -5047,7 +5078,7 @@ parseFileDescription =
sendDirectFileInline :: ChatMonad m => Contact -> FileTransferMeta -> SharedMsgId -> m ()
sendDirectFileInline ct ft sharedMsgId = do
msgDeliveryId <- sendFileInline_ ft sharedMsgId $ sendDirectContactMessage ct
withStore' $ \db -> updateSndDirectFTDelivery db ct ft msgDeliveryId
withStore $ \db -> updateSndDirectFTDelivery db ct ft msgDeliveryId
sendMemberFileInline :: ChatMonad m => GroupMember -> Connection -> FileTransferMeta -> SharedMsgId -> m ()
sendMemberFileInline m@GroupMember {groupId} conn ft sharedMsgId = do
@ -5240,7 +5271,8 @@ deleteOrUpdateMemberRecord user@User {userId} member =
Nothing -> deleteGroupMember db user member
sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64)
sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent
sendDirectContactMessage ct@Contact {activeConn = Nothing} _ = throwChatError $ CEContactNotReady ct
sendDirectContactMessage ct@Contact {activeConn = Just conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent
| connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct
| contactStatus /= CSActive = throwChatError $ CEContactNotActive ct
| connDisabled conn = throwChatError $ CEContactDisabled ct

View File

@ -436,7 +436,7 @@ data ChatResponse
| CRServerTestResult {user :: User, testServer :: AProtoServerWithAuth, testFailure :: Maybe ProtocolTestFailure}
| CRChatItemTTL {user :: User, chatItemTTL :: Maybe Int64}
| CRNetworkConfig {networkConfig :: NetworkConfig}
| CRContactInfo {user :: User, contact :: Contact, connectionStats :: ConnectionStats, customUserProfile :: Maybe Profile}
| CRContactInfo {user :: User, contact :: Contact, connectionStats_ :: Maybe ConnectionStats, customUserProfile :: Maybe Profile}
| CRGroupInfo {user :: User, groupInfo :: GroupInfo, groupSummary :: GroupSummary}
| CRGroupMemberInfo {user :: User, groupInfo :: GroupInfo, member :: GroupMember, connectionStats_ :: Maybe ConnectionStats}
| CRContactSwitchStarted {user :: User, contact :: Contact, connectionStats :: ConnectionStats}
@ -1064,7 +1064,8 @@ chatModifyVar f newValue = asks f >>= atomically . (`modifyTVar'` newValue)
{-# INLINE chatModifyVar #-}
setContactNetworkStatus :: ChatMonad' m => Contact -> NetworkStatus -> m ()
setContactNetworkStatus ct = chatModifyVar connNetworkStatuses . M.insert (contactAgentConnId ct)
setContactNetworkStatus Contact {activeConn = Nothing} _ = pure ()
setContactNetworkStatus Contact {activeConn = Just Connection {agentConnId}} status = chatModifyVar connNetworkStatuses $ M.insert agentConnId status
tryChatError :: ChatMonad m => m a -> m (Either ChatError a)
tryChatError = tryAllErrors mkChatError

View File

@ -14,7 +14,7 @@ import Data.Aeson (ToJSON)
import qualified Data.Aeson as J
import Data.Attoparsec.Text (Parser)
import qualified Data.Attoparsec.Text as A
import Data.Char (isDigit)
import Data.Char (isDigit, isPunctuation)
import Data.Either (fromRight)
import Data.Functor (($>))
import Data.List (intercalate, foldl')
@ -217,11 +217,15 @@ markdownP = mconcat <$> A.many' fragmentP
wordMD :: Text -> Markdown
wordMD s
| T.null s = unmarked s
| isUri s = case strDecode $ encodeUtf8 s of
Right cReq -> markdown (simplexUriFormat cReq) s
_ -> markdown Uri s
| isUri s =
let t = T.takeWhileEnd isPunctuation s
uri = uriMarkdown $ T.dropWhileEnd isPunctuation s
in if T.null t then uri else uri :|: unmarked t
| isEmail s = markdown Email s
| otherwise = unmarked s
uriMarkdown s = case strDecode $ encodeUtf8 s of
Right cReq -> markdown (simplexUriFormat cReq) s
_ -> markdown Uri s
isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"]
isEmail s = T.any (== '@') s && Email.isValid (encodeUtf8 s)
noFormat = pure . unmarked

View File

@ -80,7 +80,7 @@ instance ToJSON ConnectionEntity where
updateEntityConnStatus :: ConnectionEntity -> ConnStatus -> ConnectionEntity
updateEntityConnStatus connEntity connStatus = case connEntity of
RcvDirectMsgConnection c ct_ -> RcvDirectMsgConnection (st c) ((\ct -> (ct :: Contact) {activeConn = st c}) <$> ct_)
RcvDirectMsgConnection c ct_ -> RcvDirectMsgConnection (st c) ((\ct -> (ct :: Contact) {activeConn = Just $ st c}) <$> ct_)
RcvGroupMsgConnection c gInfo m@GroupMember {activeConn = c'} -> RcvGroupMsgConnection (st c) gInfo m {activeConn = st <$> c'}
SndFileConnection c ft -> SndFileConnection (st c) ft
RcvFileConnection c ft -> RcvFileConnection (st c) ft

View File

@ -78,10 +78,11 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
|]
(userId, contactId)
toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact
toContact' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] =
toContact' contactId conn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
activeConn = Just conn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)

View File

@ -189,13 +189,13 @@ createIncognitoProfile db User {userId} p = do
createIncognitoProfile_ db userId createdAt p
createDirectContact :: DB.Connection -> User -> Connection -> Profile -> ExceptT StoreError IO Contact
createDirectContact db user@User {userId} activeConn@Connection {connId, localAlias} p@Profile {preferences} = do
createDirectContact db user@User {userId} conn@Connection {connId, localAlias} p@Profile {preferences} = do
createdAt <- liftIO getCurrentTime
(localDisplayName, contactId, profileId) <- createContact_ db userId connId p localAlias Nothing createdAt (Just createdAt)
let profile = toLocalProfile profileId p localAlias
userPreferences = emptyChatPrefs
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
pure $ Contact {contactId, localDisplayName, profile, activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False}
deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO ()
deleteContactConnectionsAndFiles db userId Contact {contactId} = do
@ -213,7 +213,7 @@ deleteContactConnectionsAndFiles db userId Contact {contactId} = do
DB.execute db "DELETE FROM files WHERE user_id = ? AND contact_id = ?" (userId, contactId)
deleteContact :: DB.Connection -> User -> Contact -> IO ()
deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn = Connection {customUserProfileId}} = do
deleteContact db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
ctMember :: (Maybe ContactId) <- maybeFirstRow fromOnly $ DB.query db "SELECT contact_id FROM group_members WHERE user_id = ? AND contact_id = ? LIMIT 1" (userId, contactId)
if isNothing ctMember
@ -224,16 +224,20 @@ deleteContact db user@User {userId} Contact {contactId, localDisplayName, active
currentTs <- getCurrentTime
DB.execute db "UPDATE group_members SET contact_id = NULL, updated_at = ? WHERE user_id = ? AND contact_id = ?" (currentTs, userId, contactId)
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId
forM_ activeConn $ \Connection {customUserProfileId} ->
forM_ customUserProfileId $ \profileId ->
deleteUnusedIncognitoProfileById_ db user profileId
-- should only be used if contact is not member of any groups
deleteContactWithoutGroups :: DB.Connection -> User -> Contact -> IO ()
deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn = Connection {customUserProfileId}} = do
deleteContactWithoutGroups db user@User {userId} Contact {contactId, localDisplayName, activeConn} = do
DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId)
deleteContactProfile_ db userId contactId
DB.execute db "DELETE FROM display_names WHERE user_id = ? AND local_display_name = ?" (userId, localDisplayName)
DB.execute db "DELETE FROM contacts WHERE user_id = ? AND contact_id = ?" (userId, contactId)
forM_ customUserProfileId $ \profileId -> deleteUnusedIncognitoProfileById_ db user profileId
forM_ activeConn $ \Connection {customUserProfileId} ->
forM_ customUserProfileId $ \profileId ->
deleteUnusedIncognitoProfileById_ db user profileId
setContactDeleted :: DB.Connection -> User -> Contact -> IO ()
setContactDeleted db User {userId} Contact {contactId} = do
@ -302,19 +306,19 @@ updateContactProfile db user@User {userId} c p'
updateContact_ db userId contactId localDisplayName ldn currentTs
pure $ Right c {localDisplayName = ldn, profile, mergedPreferences}
where
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, activeConn, userPreferences} = c
Contact {contactId, localDisplayName, profile = LocalProfile {profileId, displayName, localAlias}, userPreferences} = c
Profile {displayName = newName, preferences} = p'
profile = toLocalProfile profileId p' localAlias
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
mergedPreferences = contactUserPreferences user userPreferences preferences $ contactConnIncognito c
updateContactUserPreferences :: DB.Connection -> User -> Contact -> Preferences -> IO Contact
updateContactUserPreferences db user@User {userId} c@Contact {contactId, activeConn} userPreferences = do
updateContactUserPreferences db user@User {userId} c@Contact {contactId} userPreferences = do
updatedAt <- getCurrentTime
DB.execute
db
"UPDATE contacts SET user_preferences = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?"
(userPreferences, updatedAt, userId, contactId)
let mergedPreferences = contactUserPreferences user userPreferences (preferences' c) $ connIncognito activeConn
let mergedPreferences = contactUserPreferences user userPreferences (preferences' c) $ contactConnIncognito c
pure $ c {mergedPreferences, userPreferences}
updateContactAlias :: DB.Connection -> UserId -> Contact -> LocalAlias -> IO Contact
@ -448,7 +452,8 @@ getContactByName db user localDisplayName = do
getUserContacts :: DB.Connection -> User -> IO [Contact]
getUserContacts db user@User {userId} = do
contactIds <- map fromOnly <$> DB.query db "SELECT contact_id FROM contacts WHERE user_id = ? AND deleted = 0" (Only userId)
rights <$> mapM (runExceptT . getContact db user) contactIds
contacts <- rights <$> mapM (runExceptT . getContact db user) contactIds
pure $ filter (\Contact {activeConn} -> isJust activeConn) contacts
createOrUpdateContactRequest :: DB.Connection -> User -> Int64 -> InvitationId -> VersionRange -> Profile -> Maybe XContactId -> ExceptT StoreError IO ContactOrRequest
createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (VersionRange minV maxV) Profile {displayName, fullName, image, contactLink, preferences} xContactId_ =
@ -637,9 +642,9 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
"INSERT INTO contacts (user_id, local_display_name, contact_profile_id, enable_ntfs, user_preferences, created_at, updated_at, chat_ts, xcontact_id) VALUES (?,?,?,?,?,?,?,?,?)"
(userId, localDisplayName, profileId, True, userPreferences, createdAt, createdAt, createdAt, xContactId)
contactId <- insertedRowId db
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False}
conn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn = Just conn, viaGroup = Nothing, contactUsed = False, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False}
getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64
getContactIdByName db User {userId} cName =
@ -651,7 +656,7 @@ getContact db user contactId = getContact_ db user contactId False
getContact_ :: DB.Connection -> User -> Int64 -> Bool -> ExceptT StoreError IO Contact
getContact_ db user@User {userId} contactId deleted =
ExceptT . fmap join . firstRow (toContactOrError user) (SEContactNotFound contactId) $
ExceptT . firstRow (toContact user) (SEContactNotFound contactId) $
DB.query
db
[sql|

View File

@ -205,8 +205,9 @@ createSndGroupFileTransferConnection db user@User {userId} fileId (cmdId, acId)
"INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)"
(fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs)
createSndDirectInlineFT :: DB.Connection -> Contact -> FileTransferMeta -> IO SndFileTransfer
createSndDirectInlineFT db Contact {localDisplayName = n, activeConn = Connection {connId, agentConnId}} FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, fileInline} = do
createSndDirectInlineFT :: DB.Connection -> Contact -> FileTransferMeta -> ExceptT StoreError IO SndFileTransfer
createSndDirectInlineFT _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName
createSndDirectInlineFT db Contact {localDisplayName = n, activeConn = Just Connection {connId, agentConnId}} FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, fileInline} = liftIO $ do
currentTs <- getCurrentTime
let fileStatus = FSConnected
fileInline' = Just $ fromMaybe IFMOffer fileInline
@ -227,8 +228,9 @@ createSndGroupInlineFT db GroupMember {groupMemberId, localDisplayName = n} Conn
(fileId, fileStatus, fileInline', connId, groupMemberId, currentTs, currentTs)
pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName = n, connId, agentConnId, groupMemberId = Just groupMemberId, fileStatus, fileDescrId = Nothing, fileInline = fileInline'}
updateSndDirectFTDelivery :: DB.Connection -> Contact -> FileTransferMeta -> Int64 -> IO ()
updateSndDirectFTDelivery db Contact {activeConn = Connection {connId}} FileTransferMeta {fileId} msgDeliveryId =
updateSndDirectFTDelivery :: DB.Connection -> Contact -> FileTransferMeta -> Int64 -> ExceptT StoreError IO ()
updateSndDirectFTDelivery _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName
updateSndDirectFTDelivery db Contact {activeConn = Just Connection {connId}} FileTransferMeta {fileId} msgDeliveryId = liftIO $
DB.execute
db
"UPDATE snd_files SET last_inline_msg_delivery_id = ? WHERE connection_id = ? AND file_id = ? AND file_inline IS NOT NULL"

View File

@ -309,7 +309,8 @@ createNewGroup db gVar user@User {userId} groupProfile incognitoProfile = Except
-- | creates a new group record for the group the current user was invited to, or returns an existing one
createGroupInvitation :: DB.Connection -> User -> Contact -> GroupInvitation -> Maybe ProfileId -> ExceptT StoreError IO (GroupInfo, GroupMemberId)
createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do
createGroupInvitation _ _ Contact {localDisplayName, activeConn = Nothing} _ _ = throwError $ SEContactNotReady localDisplayName
createGroupInvitation db user@User {userId} contact@Contact {contactId, activeConn = Just Connection {customUserProfileId}} GroupInvitation {fromMember, invitedMember, connRequest, groupProfile} incognitoProfileId = do
liftIO getInvitationGroupId_ >>= \case
Nothing -> createGroupInvitation_
Just gId -> do
@ -700,7 +701,8 @@ getGroupInvitation db user groupId =
DB.query db "SELECT g.inv_queue_info FROM groups g WHERE g.group_id = ? AND g.user_id = ?" (groupId, userId)
createNewContactMember :: DB.Connection -> TVar ChaChaDRG -> User -> GroupId -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> SubscriptionMode -> ExceptT StoreError IO GroupMember
createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Connection {peerChatVRange}} memberRole agentConnId connRequest subMode =
createNewContactMember _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ _ _ _ = throwError $ SEContactNotReady localDisplayName
createNewContactMember db gVar User {userId, userContactId} groupId Contact {contactId, localDisplayName, profile, activeConn = Just Connection {peerChatVRange}} memberRole agentConnId connRequest subMode =
createWithRandomId gVar $ \memId -> do
createdAt <- liftIO getCurrentTime
member@GroupMember {groupMemberId} <- createMember_ (MemberId memId) createdAt
@ -1720,15 +1722,15 @@ createMemberContact
connId <- insertedRowId db
let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, contactConnInitiated = True, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False}
pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False}
getMemberContact :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation)
getMemberContact db user contactId = do
ct <- getContact db user contactId
let Contact {contactGroupMemberId, activeConn = Connection {connId}} = ct
cReq <- getConnReqInv db connId
case contactGroupMemberId of
Just groupMemberId -> do
let Contact {contactGroupMemberId, activeConn} = ct
case (activeConn, contactGroupMemberId) of
(Just Connection {connId}, Just groupMemberId) -> do
cReq <- getConnReqInv db connId
m@GroupMember {groupId} <- getGroupMemberById db user groupMemberId
g <- getGroupInfo db user groupId
pure (g, m, ct, cReq)
@ -1757,7 +1759,7 @@ createMemberContactInvited
contactId <- createContactUpdateMember currentTs userPreferences
ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False}
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = Just ctConn, viaGroup = Nothing, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False}
m' = m {memberContactId = Just contactId}
pure (mCt', m')
where
@ -1781,13 +1783,14 @@ createMemberContactInvited
(contactId, currentTs, groupMemberId)
pure contactId
updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> Connection -> Contact -> SubscriptionMode -> IO Contact
updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = oldContactConn} subMode = do
updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> GroupInfo -> Connection -> Contact -> SubscriptionMode -> ExceptT StoreError IO Contact
updateMemberContactInvited _ _ _ _ _ Contact {localDisplayName, activeConn = Nothing} _ = throwError $ SEContactNotReady localDisplayName
updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = Just oldContactConn} subMode = liftIO $ do
updateConnectionStatus db oldContactConn ConnDeleted
activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
activeConn' <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
ct' <- updateContactStatus db user ct CSActive
ct'' <- resetMemberContactFields db ct'
pure (ct'' :: Contact) {activeConn}
pure (ct'' :: Contact) {activeConn = Just activeConn'}
resetMemberContactFields :: DB.Connection -> Contact -> IO Contact
resetMemberContactFields db ct@Contact {contactId} = do

View File

@ -493,7 +493,7 @@ getDirectChatPreviews_ db user@User {userId} = do
ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent
FROM contacts ct
JOIN contact_profiles cp ON ct.contact_profile_id = cp.contact_profile_id
JOIN connections c ON c.contact_id = ct.contact_id
LEFT JOIN connections c ON c.contact_id = ct.contact_id
LEFT JOIN (
SELECT contact_id, MAX(chat_item_id) AS MaxId
FROM chat_items
@ -510,25 +510,31 @@ getDirectChatPreviews_ db user@User {userId} = do
) ChatStats ON ChatStats.contact_id = ct.contact_id
LEFT JOIN chat_items ri ON ri.user_id = i.user_id AND ri.contact_id = i.contact_id AND ri.shared_msg_id = i.quoted_shared_msg_id
WHERE ct.user_id = ?
AND ((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1)
AND ct.is_user = 0
AND ct.deleted = 0
AND c.connection_id = (
SELECT cc_connection_id FROM (
SELECT
cc.connection_id AS cc_connection_id,
cc.created_at AS cc_created_at,
(CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord
FROM connections cc
WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id
ORDER BY cc_conn_status_ord DESC, cc_created_at DESC
LIMIT 1
AND (
(
((c.conn_level = 0 AND c.via_group_link = 0) OR ct.contact_used = 1)
AND c.connection_id = (
SELECT cc_connection_id FROM (
SELECT
cc.connection_id AS cc_connection_id,
cc.created_at AS cc_created_at,
(CASE WHEN cc.conn_status = ? OR cc.conn_status = ? THEN 1 ELSE 0 END) AS cc_conn_status_ord
FROM connections cc
WHERE cc.user_id = ct.user_id AND cc.contact_id = ct.contact_id
ORDER BY cc_conn_status_ord DESC, cc_created_at DESC
LIMIT 1
)
)
)
OR c.connection_id IS NULL
)
ORDER BY i.item_ts DESC
|]
(CISRcvNew, userId, ConnReady, ConnSndReady)
where
toDirectChatPreview :: UTCTime -> ContactRow :. ConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
toDirectChatPreview :: UTCTime -> ContactRow :. MaybeConnectionRow :. ChatStatsRow :. MaybeChatItemRow :. QuoteRow -> AChat
toDirectChatPreview currentTs (contactRow :. connRow :. statsRow :. ciRow_) =
let contact = toContact user $ contactRow :. connRow
ci_ = toDirectChatItemList currentTs ciRow_

View File

@ -252,24 +252,15 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId =
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe MsgFilter, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)
toContact :: User -> ContactRow :. ConnectionRow -> Contact
toContact :: User -> ContactRow :. MaybeConnectionRow -> Contact
toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
activeConn = toConnection connRow
activeConn = toMaybeConnection connRow
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
incognito = maybe False connIncognito activeConn
mergedPreferences = contactUserPreferences user userPreferences preferences incognito
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent}
toContactOrError :: User -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact
toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts, favorite}
in case toMaybeConnection connRow of
Just activeConn ->
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent}
_ -> Left $ SEContactNotReady localDisplayName
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile
getProfileById db userId profileId =
ExceptT . firstRow toProfile (SEProfileNotFound profileId) $

View File

@ -168,7 +168,7 @@ data Contact = Contact
{ contactId :: ContactId,
localDisplayName :: ContactName,
profile :: LocalProfile,
activeConn :: Connection,
activeConn :: Maybe Connection,
viaGroup :: Maybe Int64,
contactUsed :: Bool,
contactStatus :: ContactStatus,
@ -187,32 +187,31 @@ instance ToJSON Contact where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
contactConn :: Contact -> Connection
contactConn :: Contact -> Maybe Connection
contactConn Contact {activeConn} = activeConn
contactAgentConnId :: Contact -> AgentConnId
contactAgentConnId Contact {activeConn = Connection {agentConnId}} = agentConnId
contactConnId :: Contact -> ConnId
contactConnId = aConnId . contactConn
contactConnId :: Contact -> Maybe ConnId
contactConnId c = aConnId <$> contactConn c
type IncognitoEnabled = Bool
contactConnIncognito :: Contact -> IncognitoEnabled
contactConnIncognito = connIncognito . contactConn
contactConnIncognito = maybe False connIncognito . contactConn
contactDirect :: Contact -> Bool
contactDirect Contact {activeConn = Connection {connLevel, viaGroupLink}} = connLevel == 0 && not viaGroupLink
contactDirect Contact {activeConn} = maybe True direct activeConn
where
direct Connection {connLevel, viaGroupLink} = connLevel == 0 && not viaGroupLink
directOrUsed :: Contact -> Bool
directOrUsed ct@Contact {contactUsed} =
contactDirect ct || contactUsed
anyDirectOrUsed :: Contact -> Bool
anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed
anyDirectOrUsed Contact {contactUsed, activeConn} = ((\c -> c.connLevel) <$> activeConn) == Just 0 || contactUsed
contactReady :: Contact -> Bool
contactReady Contact {activeConn} = connReady activeConn
contactReady Contact {activeConn} = maybe False connReady activeConn
contactActive :: Contact -> Bool
contactActive Contact {contactStatus} = contactStatus == CSActive
@ -221,7 +220,7 @@ contactDeleted :: Contact -> Bool
contactDeleted Contact {contactStatus} = contactStatus == CSDeleted
contactSecurityCode :: Contact -> Maybe SecurityCode
contactSecurityCode Contact {activeConn} = connectionCode activeConn
contactSecurityCode Contact {activeConn} = connectionCode =<< activeConn
data ContactStatus
= CSActive

View File

@ -136,9 +136,11 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRGroupsList u gs -> ttyUser u $ viewGroupsList gs
CRSentGroupInvitation u g c _ ->
ttyUser u $
if viaGroupLink . contactConn $ c
then [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"]
else ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
case contactConn c of
Just Connection {viaGroupLink}
| viaGroupLink -> [ttyContact' c <> " invited to group " <> ttyGroup' g <> " via your group link"]
| otherwise -> ["invitation to join the group " <> ttyGroup' g <> " sent to " <> ttyContact' c]
Nothing -> []
CRFileTransferStatus u ftStatus -> ttyUser u $ viewFileTransferStatus ftStatus
CRFileTransferStatusXFTP u ci -> ttyUser u $ viewFileTransferStatusXFTP ci
CRUserProfile u p -> ttyUser u $ viewUserProfile p
@ -324,7 +326,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
testViewChats chats = [sShow $ map toChatView chats]
where
toChatView :: AChat -> (Text, Text, Maybe ConnStatus)
toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, Just $ connStatus activeConn)
toChatView (AChat _ (Chat (DirectChat Contact {localDisplayName, activeConn}) items _)) = ("@" <> localDisplayName, toCIPreview items Nothing, connStatus <$> activeConn)
toChatView (AChat _ (Chat (GroupChat GroupInfo {membership, localDisplayName}) items _)) = ("#" <> localDisplayName, toCIPreview items (Just membership), Nothing)
toChatView (AChat _ (Chat (ContactRequest UserContactRequest {localDisplayName}) items _)) = ("<@" <> localDisplayName, toCIPreview items Nothing, Nothing)
toChatView (AChat _ (Chat (ContactConnection PendingContactConnection {pccConnId, pccConnStatus}) items _)) = (":" <> T.pack (show pccConnId), toCIPreview items Nothing, Just pccConnStatus)
@ -1035,10 +1037,10 @@ viewNetworkConfig NetworkConfig {socksProxy, tcpTimeout} =
"use " <> highlight' "/network socks=<on/off/[ipv4]:port>[ timeout=<seconds>]" <> " to change settings"
]
viewContactInfo :: Contact -> ConnectionStats -> Maybe Profile -> [StyledString]
viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink}, activeConn} stats incognitoProfile =
["contact ID: " <> sShow contactId]
<> viewConnectionStats stats
<> maybe [] viewConnectionStats stats
<> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact l)]) contactLink
<> maybe
["you've shared main profile with this contact"]
@ -1046,7 +1048,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta
incognitoProfile
<> ["alias: " <> plain localAlias | localAlias /= ""]
<> [viewConnectionVerified (contactSecurityCode ct)]
<> [viewPeerChatVRange (peerChatVRange activeConn)]
<> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn
viewGroupInfo :: GroupInfo -> GroupSummary -> [StyledString]
viewGroupInfo GroupInfo {groupId} s =

View File

@ -49,9 +49,9 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: 3a2969c44ad2f7244188fd98ad0df504ead89d14
commit: f3045563edf58a72b515f79400bb67d2d1bfed84
- github: kazu-yamamoto/http2
commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517
commit: f5525b755ff2418e6e6ecc69e877363b0d0bcaeb
# - ../direct-sqlcipher
- github: simplex-chat/direct-sqlcipher
commit: 34309410eb2069b029b8fc1872deb1e0db123294

View File

@ -1391,10 +1391,16 @@ testXFTPMarkToReceive = do
alice <## "completed uploading file 1 (test.pdf) for bob"
bob #$> ("/_set_file_to_receive 1", id, "ok")
threadDelay 100000
bob ##> "/_stop"
bob <## "chat stopped"
bob #$> ("/_files_folder ./tests/tmp/bob_files", id, "ok")
bob #$> ("/_temp_folder ./tests/tmp/bob_xftp", id, "ok")
threadDelay 100000
bob ##> "/_start"
bob <## "chat started"

View File

@ -144,6 +144,8 @@ textWithUri :: Spec
textWithUri = describe "text with Uri" do
it "correct markdown" do
parseMarkdown "https://simplex.chat" `shouldBe` uri "https://simplex.chat"
parseMarkdown "https://simplex.chat." `shouldBe` uri "https://simplex.chat" <> "."
parseMarkdown "https://simplex.chat, hello" `shouldBe` uri "https://simplex.chat" <> ", hello"
parseMarkdown "http://simplex.chat" `shouldBe` uri "http://simplex.chat"
parseMarkdown "this is https://simplex.chat" `shouldBe` "this is " <> uri "https://simplex.chat"
parseMarkdown "https://simplex.chat site" `shouldBe` uri "https://simplex.chat" <> " site"