Merge remote-tracking branch 'origin/master' into ab/remote-discover-upd

This commit is contained in:
IC Rainbow 2023-09-29 18:42:59 +03:00
commit bf7917bd67
50 changed files with 822 additions and 286 deletions

View File

@ -1285,6 +1285,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(connection.id) m.removeChat(connection.id)
} }
} }
case let .contactDeletedByContact(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
}
}
case let .contactConnected(user, contact, _): case let .contactConnected(user, contact, _):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
await MainActor.run { await MainActor.run {

View File

@ -164,7 +164,7 @@ struct ChatInfoView: View {
// synchronizeConnectionButtonForce() // synchronizeConnectionButtonForce()
// } // }
} }
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
if let contactLink = contact.contactLink { if let contactLink = contact.contactLink {
Section { Section {
@ -181,7 +181,7 @@ struct ChatInfoView: View {
} }
} }
if contact.ready { if contact.ready && contact.active {
Section("Servers") { Section("Servers") {
networkStatusRow() networkStatusRow()
.onTapGesture { .onTapGesture {
@ -192,8 +192,7 @@ struct ChatInfoView: View {
alert = .switchAddressAlert alert = .switchAddressAlert
} }
.disabled( .disabled(
!contact.ready connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {

View File

@ -79,6 +79,7 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case .rcvDirectEvent: eventItemView()
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
case .rcvGroupEvent: eventItemView() case .rcvGroupEvent: eventItemView()

View File

@ -150,7 +150,7 @@ struct ChatView: View {
HStack { HStack {
if contact.allowsFeature(.calls) { if contact.allowsFeature(.calls) {
callButton(contact, .audio, imageName: "phone") callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
} }
Menu { Menu {
if contact.allowsFeature(.calls) { if contact.allowsFeature(.calls) {
@ -159,11 +159,11 @@ struct ChatView: View {
} label: { } label: {
Label("Video call", systemImage: "video") Label("Video call", systemImage: "video")
} }
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
} }
searchButton() searchButton()
toggleNtfsButton(chat) toggleNtfsButton(chat)
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
} }
@ -321,6 +321,7 @@ struct ChatView: View {
@ViewBuilder private func connectingText() -> some View { @ViewBuilder private func connectingText() -> some View {
if case let .direct(contact) = chat.chatInfo, if case let .direct(contact) = chat.chatInfo,
!contact.ready, !contact.ready,
contact.active,
!contact.nextSendGrpInv { !contact.nextSendGrpInv {
Text("connecting…") Text("connecting…")
.font(.caption) .font(.caption)

View File

@ -65,7 +65,7 @@ struct ChatListNavLink: View {
} }
Button { Button {
AlertManager.shared.showAlert( AlertManager.shared.showAlert(
contact.ready contact.ready || !contact.active
? deleteContactAlert(chat.chatInfo) ? deleteContactAlert(chat.chatInfo)
: deletePendingContactAlert(chat, contact) : deletePendingContactAlert(chat, contact)
) )

View File

@ -57,19 +57,26 @@ struct ChatPreviewView: View {
} }
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
if case let .group(groupInfo) = chat.chatInfo { switch chat.chatInfo {
case let .direct(contact):
if !contact.active {
inactiveIcon()
} else {
EmptyView()
}
case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memLeft: groupInactiveIcon() case .memLeft: inactiveIcon()
case .memRemoved: groupInactiveIcon() case .memRemoved: inactiveIcon()
case .memGroupDeleted: groupInactiveIcon() case .memGroupDeleted: inactiveIcon()
default: EmptyView() default: EmptyView()
} }
} else { default:
EmptyView() EmptyView()
} }
} }
@ViewBuilder private func groupInactiveIcon() -> some View { @ViewBuilder private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill") Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65)) .foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground))) .background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
@ -80,7 +87,6 @@ struct ChatPreviewView: View {
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
previewTitle(contact.verified == true ? verifiedIcon + t : t) previewTitle(contact.verified == true ? verifiedIcon + t : t)
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case let .group(groupInfo): case let .group(groupInfo):
let v = previewTitle(t) let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
@ -183,7 +189,7 @@ struct ChatPreviewView: View {
if !contact.ready { if !contact.ready {
if contact.nextSendGrpInv { if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message") chatPreviewInfoText("send direct message")
} else { } else if contact.active {
chatPreviewInfoText("connecting…") chatPreviewInfoText("connecting…")
} }
} }
@ -228,6 +234,7 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatStatusImage() -> some View { @ViewBuilder private func chatStatusImage() -> some View {
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
if contact.active {
switch (chatModel.contactNetworkStatus(contact)) { switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito) case .connected: incognitoIcon(chat.chatInfo.incognito)
case .error: case .error:
@ -239,6 +246,9 @@ struct ChatPreviewView: View {
default: default:
ProgressView() ProgressView()
} }
} else {
incognitoIcon(chat.chatInfo.incognito)
}
default: default:
incognitoIcon(chat.chatInfo.incognito) incognitoIcon(chat.chatInfo.incognito)
} }

View File

@ -462,6 +462,7 @@ public enum ChatResponse: Decodable, Error {
case contactAlreadyExists(user: UserRef, contact: Contact) case contactAlreadyExists(user: UserRef, contact: Contact)
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
case contactDeleted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact)
case contactDeletedByContact(user: UserRef, contact: Contact)
case chatCleared(user: UserRef, chatInfo: ChatInfo) case chatCleared(user: UserRef, chatInfo: ChatInfo)
case userProfileNoChange(user: User) case userProfileNoChange(user: User)
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
@ -599,6 +600,7 @@ public enum ChatResponse: Decodable, Error {
case .contactAlreadyExists: return "contactAlreadyExists" case .contactAlreadyExists: return "contactAlreadyExists"
case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted"
case .contactDeleted: return "contactDeleted" case .contactDeleted: return "contactDeleted"
case .contactDeletedByContact: return "contactDeletedByContact"
case .chatCleared: return "chatCleared" case .chatCleared: return "chatCleared"
case .userProfileNoChange: return "userProfileNoChange" case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileUpdated" case .userProfileUpdated: return "userProfileUpdated"
@ -735,6 +737,7 @@ public enum ChatResponse: Decodable, Error {
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact))
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
case .userProfileNoChange: return noDetails case .userProfileNoChange: return noDetails
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
@ -1420,6 +1423,7 @@ public enum ChatErrorType: Decodable {
case invalidConnReq case invalidConnReq
case invalidChatMessage(connection: Connection, message: String) case invalidChatMessage(connection: Connection, message: String)
case contactNotReady(contact: Contact) case contactNotReady(contact: Contact)
case contactNotActive(contact: Contact)
case contactDisabled(contact: Contact) case contactDisabled(contact: Contact)
case connectionDisabled(connection: Connection) case connectionDisabled(connection: Connection)
case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole) case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole)

View File

@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var activeConn: Connection public var activeConn: Connection
public var viaGroup: Int64? public var viaGroup: Int64?
public var contactUsed: Bool public var contactUsed: Bool
public var contactStatus: ContactStatus
public var chatSettings: ChatSettings public var chatSettings: ChatSettings
public var userPreferences: Preferences public var userPreferences: Preferences
public var mergedPreferences: ContactUserPreferences public var mergedPreferences: ContactUserPreferences
@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var id: ChatId { get { "@\(contactId)" } } public var id: ChatId { get { "@\(contactId)" } }
public var apiId: Int64 { get { contactId } } public var apiId: Int64 { get { contactId } }
public var ready: Bool { get { activeConn.connStatus == .ready } } public var ready: Bool { get { activeConn.connStatus == .ready } }
public var active: Bool { get { contactStatus == .active } }
public var sendMsgEnabled: Bool { get { public var sendMsgEnabled: Bool { get {
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|| nextSendGrpInv || nextSendGrpInv
} } } }
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
profile: LocalProfile.sampleData, profile: LocalProfile.sampleData,
activeConn: Connection.sampleData, activeConn: Connection.sampleData,
contactUsed: true, contactUsed: true,
contactStatus: .active,
chatSettings: ChatSettings.defaults, chatSettings: ChatSettings.defaults,
userPreferences: Preferences.sampleData, userPreferences: Preferences.sampleData,
mergedPreferences: ContactUserPreferences.sampleData, mergedPreferences: ContactUserPreferences.sampleData,
@ -1439,6 +1442,11 @@ public struct Contact: Identifiable, Decodable, NamedChat {
) )
} }
public enum ContactStatus: String, Decodable {
case active = "active"
case deleted = "deleted"
}
public struct ContactRef: Decodable, Equatable { public struct ContactRef: Decodable, Equatable {
var contactId: Int64 var contactId: Int64
public var agentConnId: String public var agentConnId: String
@ -2091,6 +2099,7 @@ public struct ChatItem: Identifiable, Decodable {
case .rcvDecryptionError: return showNtfDir case .rcvDecryptionError: return showNtfDir
case .rcvGroupInvitation: return showNtfDir case .rcvGroupInvitation: return showNtfDir
case .sndGroupInvitation: return showNtfDir case .sndGroupInvitation: return showNtfDir
case .rcvDirectEvent: return false
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
switch rcvGroupEvent { switch rcvGroupEvent {
case .groupUpdated: return false case .groupUpdated: return false
@ -2513,6 +2522,7 @@ public enum CIContent: Decodable, ItemContent {
case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32)
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent)
case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent) case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent)
case sndGroupEvent(sndGroupEvent: SndGroupEvent) case sndGroupEvent(sndGroupEvent: SndGroupEvent)
case rcvConnEvent(rcvConnEvent: RcvConnEvent) case rcvConnEvent(rcvConnEvent: RcvConnEvent)
@ -2542,6 +2552,7 @@ public enum CIContent: Decodable, ItemContent {
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
@ -3195,6 +3206,16 @@ public enum CIGroupInvitationStatus: String, Decodable {
case expired case expired
} }
public enum RcvDirectEvent: Decodable {
case contactDeleted
var text: String {
switch self {
case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item")
}
}
}
public enum RcvGroupEvent: Decodable { public enum RcvGroupEvent: Decodable {
case memberAdded(groupMemberId: Int64, profile: Profile) case memberAdded(groupMemberId: Int64, profile: Profile)
case memberConnected case memberConnected

View File

@ -97,7 +97,7 @@ kotlin {
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.7") implementation("org.slf4j:slf4j-simple:2.0.7")
implementation("uk.co.caprica:vlcj:4.7.0") implementation("uk.co.caprica:vlcj:4.7.3")
} }
} }
val desktopTest by getting val desktopTest by getting

View File

@ -73,6 +73,11 @@ else()
target_link_libraries(app-lib rts simplex) target_link_libraries(app-lib rts simplex)
endif() endif()
if(APPLE)
add_custom_command(TARGET app-lib POST_BUILD
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/patch-libapp-mac.sh
)
endif()
# Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to # Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to

View File

@ -0,0 +1,8 @@
#!/bin/bash
set -e
lib=libapp-lib.dylib
RPATHS=$(otool -l $lib | grep -E '/Users|/opt/|/usr/local' | cut -d' ' -f11)
for RPATH in $RPATHS; do
install_name_tool -delete_rpath $RPATH $lib
done

View File

@ -797,6 +797,7 @@ data class Contact(
val activeConn: Connection, val activeConn: Connection,
val viaGroup: Long? = null, val viaGroup: Long? = null,
val contactUsed: Boolean, val contactUsed: Boolean,
val contactStatus: ContactStatus,
val chatSettings: ChatSettings, val chatSettings: ChatSettings,
val userPreferences: ChatPreferences, val userPreferences: ChatPreferences,
val mergedPreferences: ContactUserPreferences, val mergedPreferences: ContactUserPreferences,
@ -809,8 +810,9 @@ data class Contact(
override val id get() = "@$contactId" override val id get() = "@$contactId"
override val apiId get() = contactId override val apiId get() = contactId
override val ready get() = activeConn.connStatus == ConnStatus.Ready override val ready get() = activeConn.connStatus == ConnStatus.Ready
val active get() = contactStatus == ContactStatus.Active
override val sendMsgEnabled get() = override val sendMsgEnabled get() =
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|| nextSendGrpInv || nextSendGrpInv
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
override val ntfsEnabled get() = chatSettings.enableNtfs override val ntfsEnabled get() = chatSettings.enableNtfs
@ -859,6 +861,7 @@ data class Contact(
profile = LocalProfile.sampleData, profile = LocalProfile.sampleData,
activeConn = Connection.sampleData, activeConn = Connection.sampleData,
contactUsed = true, contactUsed = true,
contactStatus = ContactStatus.Active,
chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false),
userPreferences = ChatPreferences.sampleData, userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData,
@ -869,6 +872,12 @@ data class Contact(
} }
} }
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
}
@Serializable @Serializable
class ContactRef( class ContactRef(
val contactId: Long, val contactId: Long,
@ -1471,6 +1480,7 @@ data class ChatItem (
is CIContent.RcvDecryptionError -> showNtfDir is CIContent.RcvDecryptionError -> showNtfDir
is CIContent.RcvGroupInvitation -> showNtfDir is CIContent.RcvGroupInvitation -> showNtfDir
is CIContent.SndGroupInvitation -> showNtfDir is CIContent.SndGroupInvitation -> showNtfDir
is CIContent.RcvDirectEventContent -> false
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberConnected -> false is RcvGroupEvent.MemberConnected -> false
@ -1854,6 +1864,7 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@ -1881,6 +1892,7 @@ sealed class CIContent: ItemContent {
is RcvDecryptionError -> msgDecryptError.text is RcvDecryptionError -> msgDecryptError.text
is RcvGroupInvitation -> groupInvitation.text is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text
is RcvDirectEventContent -> rcvDirectEvent.text
is RcvGroupEventContent -> rcvGroupEvent.text is RcvGroupEventContent -> rcvGroupEvent.text
is SndGroupEventContent -> sndGroupEvent.text is SndGroupEventContent -> sndGroupEvent.text
is RcvConnEventContent -> rcvConnEvent.text is RcvConnEventContent -> rcvConnEvent.text
@ -2487,6 +2499,15 @@ sealed class MsgErrorType() {
} }
} }
@Serializable
sealed class RcvDirectEvent() {
@Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent()
val text: String get() = when (this) {
is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted)
}
}
@Serializable @Serializable
sealed class RcvGroupEvent() { sealed class RcvGroupEvent() {
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()

View File

@ -1366,6 +1366,11 @@ object ChatController {
chatModel.removeChat(r.connection.id) chatModel.removeChat(r.connection.id)
} }
} }
is CR.ContactDeletedByContact -> {
if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(r.contact)
}
}
is CR.ContactConnected -> { is CR.ContactConnected -> {
if (active(r.user) && r.contact.directOrUsed) { if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(r.contact) chatModel.updateContact(r.contact)
@ -3295,6 +3300,7 @@ sealed class CR {
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
@ -3426,6 +3432,7 @@ sealed class CR {
is ContactAlreadyExists -> "contactAlreadyExists" is ContactAlreadyExists -> "contactAlreadyExists"
is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted" is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted"
is ContactDeleted -> "contactDeleted" is ContactDeleted -> "contactDeleted"
is ContactDeletedByContact -> "contactDeletedByContact"
is ChatCleared -> "chatCleared" is ChatCleared -> "chatCleared"
is UserProfileNoChange -> "userProfileNoChange" is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated" is UserProfileUpdated -> "userProfileUpdated"
@ -3554,6 +3561,7 @@ sealed class CR {
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
is ContactDeleted -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact))
is ContactDeletedByContact -> withUser(user, json.encodeToString(contact))
is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileNoChange -> withUser(user, noDetails())
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
@ -3822,6 +3830,7 @@ sealed class ChatErrorType {
is InvalidConnReq -> "invalidConnReq" is InvalidConnReq -> "invalidConnReq"
is InvalidChatMessage -> "invalidChatMessage" is InvalidChatMessage -> "invalidChatMessage"
is ContactNotReady -> "contactNotReady" is ContactNotReady -> "contactNotReady"
is ContactNotActive -> "contactNotActive"
is ContactDisabled -> "contactDisabled" is ContactDisabled -> "contactDisabled"
is ConnectionDisabled -> "connectionDisabled" is ConnectionDisabled -> "connectionDisabled"
is GroupUserRole -> "groupUserRole" is GroupUserRole -> "groupUserRole"
@ -3897,6 +3906,7 @@ sealed class ChatErrorType {
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType()
@Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType()
@Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType()
@Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType() @Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType()
@Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType() @Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType()

View File

@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.* import androidx.compose.foundation.text.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@ -291,7 +290,7 @@ fun ChatInfoLayout(
SectionDividerSpaced() SectionDividerSpaced()
} }
if (contact.ready) { if (contact.ready && contact.active) {
SectionView { SectionView {
if (connectionCode != null) { if (connectionCode != null) {
VerifyCodeButton(contact.verified, verifyClicked) VerifyCodeButton(contact.verified, verifyClicked)
@ -318,7 +317,7 @@ fun ChatInfoLayout(
SectionDividerSpaced() SectionDividerSpaced()
} }
if (contact.ready) { if (contact.ready && contact.active) {
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
SectionItemView({ SectionItemView({
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(

View File

@ -118,7 +118,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (chat.chatInfo is ChatInfo.Direct && !chat.chatInfo.contact.ready && !chat.chatInfo.contact.nextSendGrpInv) { if (
chat.chatInfo is ChatInfo.Direct
&& !chat.chatInfo.contact.ready
&& chat.chatInfo.contact.active
&& !chat.chatInfo.contact.nextSendGrpInv
) {
Text( Text(
generalGetString(MR.strings.contact_connection_pending), generalGetString(MR.strings.contact_connection_pending),
Modifier.padding(top = 4.dp), Modifier.padding(top = 4.dp),
@ -550,15 +555,15 @@ fun ChatInfoToolbar(
showMenu.value = false showMenu.value = false
startCall(CallMediaType.Audio) startCall(CallMediaType.Audio)
}, },
enabled = chat.chatInfo.contact.ready) { enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) {
Icon( Icon(
painterResource(MR.images.ic_call_500), painterResource(MR.images.ic_call_500),
stringResource(MR.strings.icon_descr_more_button), stringResource(MR.strings.icon_descr_more_button),
tint = if (chat.chatInfo.contact.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
) )
} }
} }
if (chat.chatInfo.contact.ready) { if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) {
menuItems.add { menuItems.add {
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
showMenu.value = false showMenu.value = false
@ -576,7 +581,7 @@ fun ChatInfoToolbar(
} }
} }
} }
if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready) || chat.chatInfo is ChatInfo.Group) { if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
menuItems.add { menuItems.add {
ItemAction( ItemAction(

View File

@ -127,7 +127,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
) )
if (showPreview.value) { if (showPreview.value) {
VideoPreviewImageView(preview, onClick, onLongClick) VideoPreviewImageView(preview, onClick, onLongClick)
PlayButton(brokenVideo, onLongClick = onLongClick, if (appPlatform.isAndroid) play else onClick) PlayButton(brokenVideo, onLongClick = onLongClick, play)
} }
DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/)
} }

View File

@ -352,6 +352,7 @@ 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.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.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.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
is CIContent.RcvDirectEventContent -> EventItemView()
is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) { is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) {
is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText()) is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText())
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat) is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)

View File

@ -158,12 +158,11 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
player.stop() player.stop()
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
player.enableSound(true)
snapshotFlow { isCurrentPage.value } snapshotFlow { isCurrentPage.value }
.distinctUntilChanged() .distinctUntilChanged()
.collect { .collect {
// Do not autoplay on desktop because it needs workaround if (it) play() else stop()
if (it && appPlatform.isAndroid) play() else if (!it) stop() player.enableSound(true)
} }
} }

View File

@ -42,7 +42,7 @@ fun ChatPreviewView(
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
@Composable @Composable
fun groupInactiveIcon() { fun inactiveIcon() {
Icon( Icon(
painterResource(MR.images.ic_cancel_filled), painterResource(MR.images.ic_cancel_filled),
stringResource(MR.strings.icon_descr_group_inactive), stringResource(MR.strings.icon_descr_group_inactive),
@ -53,13 +53,19 @@ fun ChatPreviewView(
@Composable @Composable
fun chatPreviewImageOverlayIcon() { fun chatPreviewImageOverlayIcon() {
if (cInfo is ChatInfo.Group) { when (cInfo) {
is ChatInfo.Direct ->
if (!cInfo.contact.active) {
inactiveIcon()
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) { when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemLeft -> groupInactiveIcon() GroupMemberStatus.MemLeft -> inactiveIcon()
GroupMemberStatus.MemRemoved -> groupInactiveIcon() GroupMemberStatus.MemRemoved -> inactiveIcon()
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon() GroupMemberStatus.MemGroupDeleted -> inactiveIcon()
else -> {} else -> {}
} }
else -> {}
} }
} }
@ -125,7 +131,7 @@ fun ChatPreviewView(
if (cInfo.contact.verified) { if (cInfo.contact.verified) {
VerifiedIcon() VerifiedIcon()
} }
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary) chatPreviewTitleText()
} }
is ChatInfo.Group -> is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) { when (cInfo.groupInfo.membership.memberStatus) {
@ -174,7 +180,7 @@ fun ChatPreviewView(
is ChatInfo.Direct -> is ChatInfo.Direct ->
if (cInfo.contact.nextSendGrpInv) { if (cInfo.contact.nextSendGrpInv) {
Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary) Text(stringResource(MR.strings.member_contact_send_direct_message), color = MaterialTheme.colors.secondary)
} else if (!cInfo.ready) { } else if (!cInfo.ready && cInfo.contact.active) {
Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary) Text(stringResource(MR.strings.contact_connection_pending), color = MaterialTheme.colors.secondary)
} }
is ChatInfo.Group -> is ChatInfo.Group ->
@ -191,6 +197,7 @@ fun ChatPreviewView(
@Composable @Composable
fun chatStatusImage() { fun chatStatusImage() {
if (cInfo is ChatInfo.Direct) { if (cInfo is ChatInfo.Direct) {
if (cInfo.contact.active) {
val descr = contactNetworkStatus?.statusString val descr = contactNetworkStatus?.statusString
when (contactNetworkStatus) { when (contactNetworkStatus) {
is NetworkStatus.Connected -> is NetworkStatus.Connected ->
@ -217,6 +224,9 @@ fun ChatPreviewView(
} else { } else {
IncognitoIcon(chat.chatInfo.incognito) IncognitoIcon(chat.chatInfo.incognito)
} }
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
} }
Row { Row {

View File

@ -1105,6 +1105,9 @@
<string name="you_rejected_group_invitation">You rejected group invitation</string> <string name="you_rejected_group_invitation">You rejected group invitation</string>
<string name="group_invitation_expired">Group invitation expired</string> <string name="group_invitation_expired">Group invitation expired</string>
<!-- Direct event chat items -->
<string name="rcv_direct_event_contact_deleted">deleted contact</string>
<!-- Group event chat items --> <!-- Group event chat items -->
<string name="rcv_group_event_member_added">invited %1$s</string> <string name="rcv_group_event_member_added">invited %1$s</string>
<string name="rcv_group_event_member_connected">connected</string> <string name="rcv_group_event_member_connected">connected</string>

View File

@ -0,0 +1,79 @@
package org.jetbrains.compose.videoplayer
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asComposeImageBitmap
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.ImageInfo
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.CallbackVideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurfaceAdapters
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer
import javax.swing.SwingUtilities
// https://github.com/JetBrains/compose-multiplatform/pull/3336/files
internal class SkiaBitmapVideoSurface : VideoSurface(VideoSurfaceAdapters.getVideoSurfaceAdapter()) {
private val videoSurface = SkiaBitmapVideoSurface()
private lateinit var imageInfo: ImageInfo
private lateinit var frameBytes: ByteArray
private val skiaBitmap: Bitmap = Bitmap()
private val composeBitmap = mutableStateOf<ImageBitmap?>(null)
val bitmap: State<ImageBitmap?> = composeBitmap
override fun attach(mediaPlayer: MediaPlayer) {
videoSurface.attach(mediaPlayer)
}
private inner class SkiaBitmapBufferFormatCallback : BufferFormatCallback {
private var sourceWidth: Int = 0
private var sourceHeight: Int = 0
override fun getBufferFormat(sourceWidth: Int, sourceHeight: Int): BufferFormat {
this.sourceWidth = sourceWidth
this.sourceHeight = sourceHeight
return RV32BufferFormat(sourceWidth, sourceHeight)
}
override fun allocatedBuffers(buffers: Array<ByteBuffer>) {
frameBytes = buffers[0].run { ByteArray(remaining()).also(::get) }
imageInfo = ImageInfo(
sourceWidth,
sourceHeight,
ColorType.BGRA_8888,
ColorAlphaType.PREMUL,
)
}
}
private inner class SkiaBitmapRenderCallback : RenderCallback {
override fun display(
mediaPlayer: MediaPlayer,
nativeBuffers: Array<ByteBuffer>,
bufferFormat: BufferFormat,
) {
SwingUtilities.invokeLater {
nativeBuffers[0].rewind()
nativeBuffers[0].get(frameBytes)
skiaBitmap.installPixels(imageInfo, frameBytes, bufferFormat.width * 4)
composeBitmap.value = skiaBitmap.asComposeImageBitmap()
}
}
}
private inner class SkiaBitmapVideoSurface : CallbackVideoSurface(
SkiaBitmapBufferFormatCallback(),
SkiaBitmapRenderCallback(),
true,
videoSurfaceAdapter,
)
}

View File

@ -6,6 +6,8 @@ import boofcv.struct.image.GrayU8
import chat.simplex.res.MR import chat.simplex.res.MR
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import java.awt.RenderingHints import java.awt.RenderingHints
import java.awt.geom.AffineTransform
import java.awt.image.AffineTransformOp
import java.awt.image.BufferedImage import java.awt.image.BufferedImage
import java.io.* import java.io.*
import java.net.URI import java.net.URI
@ -171,3 +173,37 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean {
@Suppress("NewApi") @Suppress("NewApi")
actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap = actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap =
Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap() Image.makeFromEncoded(inputStream.readAllBytes()).toComposeImageBitmap()
// https://stackoverflow.com/a/68926993
fun BufferedImage.rotate(angle: Double): BufferedImage {
val sin = Math.abs(Math.sin(Math.toRadians(angle)))
val cos = Math.abs(Math.cos(Math.toRadians(angle)))
val w = width
val h = height
val neww = Math.floor(w * cos + h * sin).toInt()
val newh = Math.floor(h * cos + w * sin).toInt()
val rotated = BufferedImage(neww, newh, type)
val graphic = rotated.createGraphics()
graphic.translate((neww - w) / 2, (newh - h) / 2)
graphic.rotate(Math.toRadians(angle), (w / 2).toDouble(), (h / 2).toDouble())
graphic.drawRenderedImage(this, null)
graphic.dispose()
return rotated
}
// https://stackoverflow.com/a/9559043
fun BufferedImage.flip(vertically: Boolean, horizontally: Boolean): BufferedImage {
if (!vertically && !horizontally) return this
val tx: AffineTransform
if (vertically && horizontally) {
tx = AffineTransform.getScaleInstance(-1.0, -1.0)
tx.translate(-width.toDouble(), -height.toDouble())
} else if (vertically) {
tx = AffineTransform.getScaleInstance(1.0, -1.0)
tx.translate(0.0, -height.toDouble())
} else {
tx = AffineTransform.getScaleInstance(-1.0, 1.0)
tx.translate(-width.toDouble(), 0.0)
}
return AffineTransformOp(tx, AffineTransformOp.TYPE_NEAREST_NEIGHBOR).filter(this, null)
}

View File

@ -2,17 +2,20 @@ package chat.simplex.common.platform
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.toComposeImageBitmap
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.* import kotlinx.coroutines.*
import uk.co.caprica.vlcj.player.base.MediaPlayer import uk.co.caprica.vlcj.media.VideoOrientation
import uk.co.caprica.vlcj.player.base.*
import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent import uk.co.caprica.vlcj.player.component.CallbackMediaPlayerComponent
import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent import uk.co.caprica.vlcj.player.component.EmbeddedMediaPlayerComponent
import java.awt.Component import java.awt.Component
import java.awt.image.BufferedImage
import java.io.File import java.io.File
import java.net.URI import java.net.URI
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.max import kotlin.math.max
actual class VideoPlayer actual constructor( actual class VideoPlayer actual constructor(
@ -29,17 +32,14 @@ actual class VideoPlayer actual constructor(
override val duration: MutableState<Long> = mutableStateOf(0L) override val duration: MutableState<Long> = mutableStateOf(0L)
override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview) override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
val mediaPlayerComponent = initializeMediaPlayerComponent() val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } }
val player by lazy { mediaPlayerComponent.mediaPlayer() } val player by lazy { mediaPlayerComponent.mediaPlayer() }
init { init {
withBGApi {
setPreviewAndDuration() setPreviewAndDuration()
} }
}
private val currentVolume: Int by lazy { player.audio().volume() } private var isReleased: AtomicBoolean = AtomicBoolean(false)
private var isReleased: Boolean = false
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null) private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
private var progressJob: Job? = null private var progressJob: Job? = null
@ -48,6 +48,7 @@ actual class VideoPlayer actual constructor(
PLAYING, PAUSED, STOPPED PLAYING, PAUSED, STOPPED
} }
/** Should be called in [playerThread]. Otherwise, it creates deadlocks in [player.stop] and [player.release] calls */
private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean { private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean {
val filepath = getAppFilePath(uri) val filepath = getAppFilePath(uri)
if (filepath == null || !File(filepath).exists()) { if (filepath == null || !File(filepath).exists()) {
@ -87,7 +88,7 @@ actual class VideoPlayer actual constructor(
// Player can only be accessed in one specific thread // Player can only be accessed in one specific thread
progressJob = CoroutineScope(Dispatchers.Main).launch { progressJob = CoroutineScope(Dispatchers.Main).launch {
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
while (isActive && !isReleased && player.isPlaying) { while (isActive && !isReleased.get() && player.isPlaying) {
// Even when current position is equal to duration, the player has isPlaying == true for some time, // Even when current position is equal to duration, the player has isPlaying == true for some time,
// so help to make the playback stopped in UI immediately // so help to make the playback stopped in UI immediately
if (player.currentPosition == player.duration) { if (player.currentPosition == player.duration) {
@ -97,7 +98,7 @@ actual class VideoPlayer actual constructor(
delay(50) delay(50)
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING) onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
} }
if (isActive && !isReleased) { if (isActive && !isReleased.get()) {
onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED) onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED)
} }
onProgressUpdate(null, TrackState.PAUSED) onProgressUpdate(null, TrackState.PAUSED)
@ -107,10 +108,12 @@ actual class VideoPlayer actual constructor(
} }
override fun stop() { override fun stop() {
if (isReleased || !videoPlaying.value) return if (isReleased.get() || !videoPlaying.value) return
player.controls().stop() playerThread.execute {
player.stop()
stopListener() stopListener()
} }
}
private fun stopListener() { private fun stopListener() {
val afterCoroutineCancel: CompletionHandler = { val afterCoroutineCancel: CompletionHandler = {
@ -133,6 +136,7 @@ actual class VideoPlayer actual constructor(
if (progress.value == duration.value) { if (progress.value == duration.value) {
progress.value = 0 progress.value = 0
} }
playerThread.execute {
videoPlaying.value = start(progress.value) { pro, _ -> videoPlaying.value = start(progress.value) { pro, _ ->
if (pro != null) { if (pro != null) {
progress.value = pro progress.value = pro
@ -147,31 +151,42 @@ actual class VideoPlayer actual constructor(
} }
} }
} }
override fun enableSound(enable: Boolean): Boolean {
if (isReleased) return false
if (soundEnabled.value == enable) return false
soundEnabled.value = enable
player.audio().setVolume(if (enable) currentVolume else 0)
return true
} }
override fun release(remove: Boolean) { withApi { override fun enableSound(enable: Boolean): Boolean {
if (isReleased) return@withApi // Impossible to change volume for only one player. It changes for every player
isReleased = true // https://github.com/caprica/vlcj/issues/985
// TODO return false
/** [player.release] freezes thread for some reason. It happens periodically. So doing this we don't see the freeze, but it's still there */ /*if (isReleased.get() || soundEnabled.value == enable) return false
if (player.isPlaying) player.stop() soundEnabled.value = enable
CoroutineScope(Dispatchers.IO).launch { player.release() } playerThread.execute {
player.audio().isMute = !enable
}
return true*/
}
override fun release(remove: Boolean) {
CoroutineScope(playerThread.asCoroutineDispatcher()).launch {
if (isReleased.get()) return@launch
isReleased.set(true)
if (player.isPlaying) {
player.stop()
}
if (usePool) {
putPlayer(mediaPlayerComponent)
} else {
player.release()
}
if (remove) { if (remove) {
VideoPlayerHolder.players.remove(uri to gallery) VideoPlayerHolder.players.remove(uri to gallery)
} }
}} }
}
private val MediaPlayer.currentPosition: Int private val MediaPlayer.currentPosition: Int
get() = if (isReleased) 0 else max(0, player.status().time().toInt()) get() = if (isReleased.get()) 0 else max(0, status().time().toInt())
private suspend fun setPreviewAndDuration() { private fun setPreviewAndDuration() {
// It freezes main thread, doing it in IO thread // It freezes main thread, doing it in IO thread
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) } val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) }
@ -182,6 +197,15 @@ actual class VideoPlayer actual constructor(
} }
} }
companion object {
private val usePool = false
private fun Component.mediaPlayer() = when (this) {
is CallbackMediaPlayerComponent -> mediaPlayer()
is EmbeddedMediaPlayerComponent -> mediaPlayer()
else -> error("mediaPlayer() can only be called on vlcj player components")
}
private fun initializeMediaPlayerComponent(): Component { private fun initializeMediaPlayerComponent(): Component {
return if (desktopPlatform.isMac()) { return if (desktopPlatform.isMac()) {
CallbackMediaPlayerComponent() CallbackMediaPlayerComponent()
@ -190,27 +214,62 @@ actual class VideoPlayer actual constructor(
} }
} }
private fun Component.mediaPlayer() = when (this) { suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) {
is CallbackMediaPlayerComponent -> mediaPlayer() val mediaComponent = getOrCreateHelperPlayer()
is EmbeddedMediaPlayerComponent -> mediaPlayer() val player = mediaComponent.mediaPlayer()
else -> error("mediaPlayer() can only be called on vlcj player components")
}
companion object {
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration {
val player = CallbackMediaPlayerComponent().mediaPlayer()
if (uri == null || !File(uri.rawPath).exists()) { if (uri == null || !File(uri.rawPath).exists()) {
return VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L) return@withContext VideoPlayerInterface.PreviewAndDuration(preview = defaultPreview, timestamp = 0L, duration = 0L)
} }
player.media().startPaused(uri.toString().replaceFirst("file:", "file://")) player.media().startPaused(uri.toString().replaceFirst("file:", "file://"))
val start = System.currentTimeMillis() val start = System.currentTimeMillis()
while (player.snapshots()?.get() == null && start + 5000 > System.currentTimeMillis()) { var snap: BufferedImage? = null
while (snap == null && start + 5000 > System.currentTimeMillis()) {
snap = player.snapshots()?.get()
delay(10) delay(10)
} }
val preview = player.snapshots()?.get()?.toComposeImageBitmap() val orientation = player.media().info().videoTracks().first().orientation()
val preview: ImageBitmap? = when (orientation) {
VideoOrientation.TOP_LEFT -> snap
VideoOrientation.TOP_RIGHT -> snap?.flip(false, true)
VideoOrientation.BOTTOM_LEFT -> snap?.flip(true, false)
VideoOrientation.BOTTOM_RIGHT -> snap?.rotate(180.0)
VideoOrientation.LEFT_TOP -> snap /* Transposed */
VideoOrientation.LEFT_BOTTOM -> snap?.rotate(-90.0)
VideoOrientation.RIGHT_TOP -> snap?.rotate(90.0)
VideoOrientation.RIGHT_BOTTOM -> snap /* Anti-transposed */
else -> snap
}?.toComposeImageBitmap()
val duration = player.duration.toLong() val duration = player.duration.toLong()
CoroutineScope(Dispatchers.IO).launch { player.release() } player.stop()
return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration) putHelperPlayer(mediaComponent)
return@withContext VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
} }
val playerThread = Executors.newSingleThreadExecutor()
private val playersPool: ArrayList<Component> = ArrayList()
private val helperPlayersPool: ArrayList<CallbackMediaPlayerComponent> = ArrayList()
private fun getOrCreatePlayer(): Component = playersPool.removeFirstOrNull() ?: createNew()
private fun createNew(): Component =
initializeMediaPlayerComponent().apply {
mediaPlayer().events().addMediaPlayerEventListener(object: MediaPlayerEventAdapter() {
override fun mediaPlayerReady(mediaPlayer: MediaPlayer?) {
playerThread.execute {
mediaPlayer?.audio()?.setVolume(100)
mediaPlayer?.audio()?.isMute = false
}
}
override fun stopped(mediaPlayer: MediaPlayer?) {
//playerThread.execute { mediaPlayer().videoSurface().set(null) }
}
})
}
private fun putPlayer(player: Component) = playersPool.add(player)
private fun getOrCreateHelperPlayer(): CallbackMediaPlayerComponent = helperPlayersPool.removeFirstOrNull() ?: CallbackMediaPlayerComponent()
private fun putHelperPlayer(player: CallbackMediaPlayerComponent) = helperPlayersPool.add(player)
} }
} }

View File

@ -1,12 +1,29 @@
package chat.simplex.common.views.chat.item package chat.simplex.common.views.chat.item
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import chat.simplex.common.platform.VideoPlayer import chat.simplex.common.platform.VideoPlayer
import chat.simplex.common.platform.isPlaying
import chat.simplex.common.views.helpers.onRightClick
@Composable @Composable
actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {} actual fun PlayerView(player: VideoPlayer, width: Dp, onClick: () -> Unit, onLongClick: () -> Unit, stop: () -> Unit) {
Box {
SurfaceFromPlayer(player,
Modifier
.width(width)
.combinedClickable(
onLongClick = onLongClick,
onClick = { if (player.player.isPlaying) stop() else onClick() }
)
.onRightClick(onLongClick)
)
}
}
@Composable @Composable
actual fun LocalWindowWidth(): Dp { actual fun LocalWindowWidth(): Dp {

View File

@ -6,17 +6,15 @@ import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.awt.SwingPanel
import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.simplexWindowState
import chat.simplex.common.views.helpers.getBitmapFromByteArray import chat.simplex.common.views.helpers.getBitmapFromByteArray
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface
import kotlin.math.max import kotlin.math.max
@Composable @Composable
@ -28,30 +26,40 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
modifier = modifier, modifier = modifier,
) )
} }
@Composable @Composable
actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) { actual fun FullScreenVideoView(player: VideoPlayer, modifier: Modifier, close: () -> Unit) {
// Workaround. Without changing size of the window the screen flashes a lot even if it's not being recomposed
LaunchedEffect(Unit) {
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width + 1.dp)
delay(50)
player.play(true)
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = simplexWindowState.windowState.size.width - 1.dp)
}
Box { Box {
Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) { Box(Modifier.fillMaxSize().padding(bottom = 50.dp)) {
val factory = remember { { player.mediaPlayerComponent } } SurfaceFromPlayer(player, modifier)
SwingPanel( IconButton(onClick = close, Modifier.padding(top = 5.dp)) {
background = Color.Transparent, Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
modifier = Modifier,
factory = factory
)
} }
Controls(player, close) }
Controls(player)
} }
} }
@Composable @Composable
private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) { fun BoxScope.SurfaceFromPlayer(player: VideoPlayer, modifier: Modifier) {
val surface = remember {
SkiaBitmapVideoSurface().also {
player.player.videoSurface().set(it)
}
}
surface.bitmap.value?.let { bitmap ->
Image(
bitmap,
modifier = modifier.align(Alignment.Center),
contentDescription = null,
contentScale = ContentScale.Fit,
alignment = Alignment.Center,
)
}
}
@Composable
private fun BoxScope.Controls(player: VideoPlayer) {
val playing = remember(player) { player.videoPlaying } val playing = remember(player) { player.videoPlaying }
val progress = remember(player) { player.progress } val progress = remember(player) { player.progress }
val duration = remember(player) { player.duration } val duration = remember(player) { player.duration }
@ -62,10 +70,7 @@ private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
Slider( Slider(
value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()), value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()),
onValueChange = { player.player.seekTo((it * duration.value).toInt()) }, onValueChange = { player.player.seekTo((it * duration.value).toInt()) },
modifier = Modifier.fillMaxWidth().weight(1f) modifier = Modifier.fillMaxWidth()
) )
IconButton(onClick = close,) {
Icon(painterResource(MR.images.ic_close), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
}
} }
} }

View File

@ -38,9 +38,15 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t
- `master` - branch for beta version releases (GHC 9.6.2). - `master` - branch for beta version releases (GHC 9.6.2).
- `master-android` - used to build beta Android core library with Nix (GHC 8.10.7). - `master-ghc8107` - branch for beta version releases (GHC 8.10.7).
- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7) this branch should be the same as `master-android` except Nix configuration files. - `master-android` - used to build beta Android core library with Nix (GHC 8.10.7), same as `master-ghc8107`
- `master-ios` - used to build beta iOS core library with Nix (GHC 8.10.7).
- `windows-ghc8107` - branch for windows core library build (GHC 8.10.7).
`master-ios` and `windows-ghc8107` branches should be the same as `master-ghc8107` except Nix configuration files.
**In simplexmq repo** **In simplexmq repo**
@ -54,24 +60,30 @@ You will have to add `/opt/homebrew/opt/openssl@1.1/bin` to your PATH in order t
2. If simplexmq repo was changed, to build mobile core libraries you need to merge its `master` branch into `master-ghc8107` branch. 2. If simplexmq repo was changed, to build mobile core libraries you need to merge its `master` branch into `master-ghc8107` branch.
3. To build Android core library: 3. To build core libraries for Android, iOS and windows:
- merge `master` branch to `master-android` branch. - merge `master` branch to `master-ghc8107` branch.
- update `simplexmq` commit in `master-ghc8107` branch to the commit in `master-ghc8107` branch (probably, when resolving merge conflicts).
- update code to be compatible with GHC 8.10.7 (see below). - update code to be compatible with GHC 8.10.7 (see below).
- update `simplexmq` commit in `master-android` branch to the commit in `master-ghc8107` branch.
- push to GitHub. - push to GitHub.
4. To build iOS core library, merge `master-android` branch to `master-ios` branch, and push to GitHub. 4. To build Android core library, merge `master-ghc8107` branch to `master-android` branch, and push to GitHub.
5. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release. 5. To build iOS core library, merge `master-ghc8107` branch to `master-ios` branch, and push to GitHub.
6. After the public release to App Store and Play Store, merge: 6. To build windows core library, merge `master-ghc8107` branch to `windows-ghc8107` branch, and push to GitHub.
7. To build Desktop and CLI apps, make tag in `master` branch, APK files should be attached to the release.
8. After the public release to App Store and Play Store, merge:
- `master` to `stable` - `master` to `stable`
- `master` to `master-android` (and compile/update code) - `master` to `master-ghc8107` (and compile/update code)
- `master-android` to `master-ios` - `master-ghc8107` to `master-android`
- `master-ghc8107` to `master-ios`
- `master-ghc8107` to `windows-ghc8107`
- `master-android` to `stable-android` - `master-android` to `stable-android`
- `master-ios` to `stable-ios` - `master-ios` to `stable-ios`
7. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases. 9. Independently, `master` branch of simplexmq repo should be merged to `stable` branch on stable releases.
## Differences between GHC 8.10.7 and GHC 9.6.2 ## Differences between GHC 8.10.7 and GHC 9.6.2

View File

@ -15,31 +15,30 @@ We want to add up to 3 people to the team.
## Who we are looking for ## Who we are looking for
### Systems Haskell engineer ### Application Haskell engineer
You are a servers/network/Haskell expert: You are an expert in language models, databases and Haskell:
- network libraries. - expert knowledge of SQL.
- exception handling, concurrency, STM. - exception handling, concurrency, STM.
- type systems - we use ad hoc dependent types a lot. - type systems - we use ad hoc dependent types a lot.
- strictness. - experience integrating open-source language models.
- has some expertise in network protocols, cryptography and general information security principles and approaches. - experience developing community-centric applications.
- interested to build the next generation of messaging network. - interested to build the next generation of messaging network.
You will be focussed mostly on our servers code, and will also contribute to the core client code written in Haskell. You will be focussed mostly on our client applications, and will also contribute to the servers also written in Haskell.
### iOS / Mac engineer
### Product engineer (iOS) You are an expert in Apple platforms, including:
- iOS and Mac platform architecture.
You are a product UX expert who designs great user experiences directly in iOS code: - Swift and Objective-C.
- iOS and Mac platforms, including: - SwiftUI and UIKit.
- SwiftUI and UIKit. - extensions, including notification service extension and sharing extension.
- extensions, including notification service extension and sharing extension. - low level inter-process communication primitives for concurrency.
- low level inter-process communication primitives for concurrency.
- interested about creating the next generation of UX for a communication/social network. - interested about creating the next generation of UX for a communication/social network.
Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin Jetpack Compose for our Android and desktop apps. Knowledge of Android and Kotlin Multiplatform would be a bonus - we use Kotlin Jetpack Compose for our Android and desktop apps.
## About you ## About you
- **Passionate about joining SimpleX Chat team**: - **Passionate about joining SimpleX Chat team**:

View File

@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
set -e
OS=mac OS=mac
ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}" ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}"
GHC_VERSION=9.6.2 GHC_VERSION=9.6.2
@ -18,7 +20,7 @@ rm -rf $BUILD_DIR
cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi" cabal build lib:simplex-chat lib:simplex-chat --ghc-options="-optl-Wl,-rpath,@loader_path -optl-Wl,-L$GHC_LIBS_DIR/$ARCH-osx-ghc-$GHC_VERSION -optl-lHSrts_thr-ghc$GHC_VERSION -optl-lffi"
cd $BUILD_DIR/build cd $BUILD_DIR/build
mkdir deps 2> /dev/null mkdir deps 2> /dev/null || true
# It's not included by default for some reason. Compiled lib tries to find system one but it's not always available # It's not included by default for some reason. Compiled lib tries to find system one but it's not always available
#cp $GHC_LIBS_DIR/libffi.dylib ./deps #cp $GHC_LIBS_DIR/libffi.dylib ./deps
@ -79,13 +81,6 @@ copy_deps $LIB
cp $(ghc --print-libdir)/$ARCH-osx-ghc-$GHC_VERSION/libHSghc-boot-th-$GHC_VERSION-ghc$GHC_VERSION.dylib deps cp $(ghc --print-libdir)/$ARCH-osx-ghc-$GHC_VERSION/libHSghc-boot-th-$GHC_VERSION-ghc$GHC_VERSION.dylib deps
rm deps/`basename $LIB` rm deps/`basename $LIB`
if [ -e deps/libHSdrct-*.$LIB_EXT ]; then
LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT
cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT
chmod 755 deps/libcrypto.1.1.$LIB_EXT
fi
cd - cd -
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
@ -95,4 +90,39 @@ rm -rf apps/multiplatform/desktop/build/cmake
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp -r $BUILD_DIR/build/deps apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/ cp $BUILD_DIR/build/libHSsimplex-chat-*-inplace-ghc*.$LIB_EXT apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
cd apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
LIBCRYPTO_PATH=$(otool -l deps/libHSdrct-*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSdrct-*.$LIB_EXT
cp $LIBCRYPTO_PATH deps/libcrypto.1.1.$LIB_EXT
chmod 755 deps/libcrypto.1.1.$LIB_EXT
install_name_tool -id "libcrypto.1.1.$LIB_EXT" deps/libcrypto.1.1.$LIB_EXT
install_name_tool -id "libffi.8.$LIB_EXT" deps/libffi.$LIB_EXT
LIBCRYPTO_PATH=$(otool -l $LIB | grep libcrypto | cut -d' ' -f11)
if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT $LIB
fi
LIBCRYPTO_PATH=$(otool -l deps/libHSsmplxmq*.$LIB_EXT | grep libcrypto | cut -d' ' -f11)
if [ -n "$LIBCRYPTO_PATH" ]; then
install_name_tool -change $LIBCRYPTO_PATH @rpath/libcrypto.1.1.$LIB_EXT deps/libHSsmplxmq*.$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
install_name_tool -delete_rpath $RPATH $lib
done
done
LOCAL_DIRS=`for lib in $(find . -type f -name "*.$LIB_EXT"); do otool -l $lib | grep -E "/Users|/opt/|/usr/local" && echo $lib || true; done`
if [ -n "$LOCAL_DIRS" ]; then
echo These libs still point to local directories:
echo $LOCAL_DIRS
exit 1
fi
cd -
scripts/desktop/prepare-vlc-mac.sh scripts/desktop/prepare-vlc-mac.sh

View File

@ -1,5 +1,5 @@
{ {
"https://github.com/simplex-chat/simplexmq.git"."8d47f690838371bc848e4b31a4b09ef6bf67ccc5" = "1pwasv22ii3wy4xchaknlwczmy5ws7adx7gg2g58lxzrgdjm3650"; "https://github.com/simplex-chat/simplexmq.git"."ec1b72cb8013a65a5d9783104a47ae44f5730089" = "1lz5rvgxp242zg95r9zd9j50y45314cf8nfpjg1qsa55nrk2w19b";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb"; "https://github.com/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp";

View File

@ -114,6 +114,7 @@ library
Simplex.Chat.Migrations.M20230913_member_contacts Simplex.Chat.Migrations.M20230913_member_contacts
Simplex.Chat.Migrations.M20230914_member_probes Simplex.Chat.Migrations.M20230914_member_probes
Simplex.Chat.Migrations.M20230922_remote_controller Simplex.Chat.Migrations.M20230922_remote_controller
Simplex.Chat.Migrations.M20230926_contact_status
Simplex.Chat.Mobile Simplex.Chat.Mobile
Simplex.Chat.Mobile.File Simplex.Chat.Mobile.File
Simplex.Chat.Mobile.Shared Simplex.Chat.Mobile.Shared

View File

@ -901,14 +901,15 @@ processChatCommand = \case
liftIO $ updateGroupUnreadChat db user groupInfo unreadChat liftIO $ updateGroupUnreadChat db user groupInfo unreadChat
ok user ok user
_ -> pure $ chatCmdError (Just user) "not supported" _ -> pure $ chatCmdError (Just user) "not supported"
APIDeleteChat (ChatRef cType chatId) -> withUser $ \user@User {userId} -> case cType of APIDeleteChat (ChatRef cType chatId) notify -> withUser $ \user@User {userId} -> case cType of
CTDirect -> do CTDirect -> do
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct)
withChatLock "deleteChat direct" . procCmd $ do withChatLock "deleteChat direct" . procCmd $ do
fileAgentConnIds <- concat <$> forM filesInfo (deleteFile user) deleteFilesAndConns user filesInfo
deleteAgentConnectionsAsync user $ fileAgentConnIds <> contactConnIds when (contactActive ct && notify) . void $ sendDirectContactMessage ct XDirectDel
contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct)
deleteAgentConnectionsAsync user contactConnIds
-- functions below are called in separate transactions to prevent crashes on android -- functions below are called in separate transactions to prevent crashes on android
-- (possibly, race condition on integrity check?) -- (possibly, race condition on integrity check?)
withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct
@ -1331,7 +1332,7 @@ processChatCommand = \case
ConnectSimplex incognito -> withUser $ \user -> ConnectSimplex incognito -> withUser $ \user ->
-- [incognito] generate profile to send -- [incognito] generate profile to send
connectViaContact user incognito adminContactReq connectViaContact user incognito adminContactReq
DeleteContact cName -> withContactName cName $ APIDeleteChat . ChatRef CTDirect DeleteContact cName -> withContactName cName $ \ctId -> APIDeleteChat (ChatRef CTDirect ctId) True
ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect ClearContact cName -> withContactName cName $ APIClearChat . ChatRef CTDirect
APIListContacts userId -> withUserId userId $ \user -> APIListContacts userId -> withUserId userId $ \user ->
CRContactsList user <$> withStore' (`getUserContacts` user) CRContactsList user <$> withStore' (`getUserContacts` user)
@ -1426,7 +1427,7 @@ processChatCommand = \case
processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc
SendMessageBroadcast msg -> withUser $ \user -> do SendMessageBroadcast msg -> withUser $ \user -> do
contacts <- withStore' (`getUserContacts` user) contacts <- withStore' (`getUserContacts` user)
let cts = filter (\ct -> isReady ct && directOrUsed ct) contacts let cts = filter (\ct -> isReady ct && contactActive ct && directOrUsed ct) contacts
ChatConfig {logLevel} <- asks config ChatConfig {logLevel} <- asks config
withChatLock "sendMessageBroadcast" . procCmd $ do withChatLock "sendMessageBroadcast" . procCmd $ do
(successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts (successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts
@ -1594,7 +1595,7 @@ processChatCommand = \case
processChatCommand $ APILeaveGroup groupId processChatCommand $ APILeaveGroup groupId
DeleteGroup gName -> withUser $ \user -> do DeleteGroup gName -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) True
ClearGroup gName -> withUser $ \user -> do ClearGroup gName -> withUser $ \user -> do
groupId <- withStore $ \db -> getGroupIdByName db user gName groupId <- withStore $ \db -> getGroupIdByName db user gName
processChatCommand $ APIClearChat (ChatRef CTGroup groupId) processChatCommand $ APIClearChat (ChatRef CTGroup groupId)
@ -1988,7 +1989,7 @@ processChatCommand = \case
-- read contacts before user update to correctly merge preferences -- read contacts before user update to correctly merge preferences
-- [incognito] filter out contacts with whom user has incognito connections -- [incognito] filter out contacts with whom user has incognito connections
contacts <- contacts <-
filter (\ct -> isReady ct && not (contactConnIncognito ct)) filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct))
<$> withStore' (`getUserContacts` user) <$> withStore' (`getUserContacts` user)
user' <- updateUser user' <- updateUser
asks currentUser >>= atomically . (`writeTVar` Just user') asks currentUser >>= atomically . (`writeTVar` Just user')
@ -2574,7 +2575,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
getContactConns :: m ([ConnId], Map ConnId Contact) getContactConns :: m ([ConnId], Map ConnId Contact)
getContactConns = do getContactConns = do
cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts
let connIds = map contactConnId cts let connIds = map contactConnId (filter contactActive cts)
pure (connIds, M.fromList $ zip connIds cts) pure (connIds, M.fromList $ zip connIds cts)
getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact) getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact)
getUserContactLinkConns = do getUserContactLinkConns = do
@ -2584,7 +2585,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember) getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember)
getGroupMemberConns = do getGroupMemberConns = do
gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") getUserGroups gs <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserGroups") getUserGroups
let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) ms) gs let mPairs = concatMap (\(Group _ ms) -> mapMaybe (\m -> (,m) <$> memberConnId m) (filter (not . memberRemoved) ms)) gs
pure (gs, map fst mPairs, M.fromList mPairs) pure (gs, map fst mPairs, M.fromList mPairs)
getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer) getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer)
getSndFileTransferConns = do getSndFileTransferConns = do
@ -3050,6 +3051,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta
XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta
XInfo p -> xInfo ct' p XInfo p -> xInfo ct' p
XDirectDel -> xDirectDel ct' msg msgMeta
XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta
XInfoProbe probe -> xInfoProbe (CGMContact ct') probe XInfoProbe probe -> xInfoProbe (CGMContact ct') probe
XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash
@ -4254,6 +4256,24 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
xInfo :: Contact -> Profile -> m () xInfo :: Contact -> Profile -> m ()
xInfo c p' = void $ processContactProfileUpdate c p' True xInfo c p' = void $ processContactProfileUpdate c p' True
xDirectDel :: Contact -> RcvMessage -> MsgMeta -> m ()
xDirectDel c msg msgMeta =
if directOrUsed c
then do
checkIntegrityCreateItem (CDDirectRcv c) msgMeta
ct' <- withStore' $ \db -> updateContactStatus db user c CSDeleted
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
ci <- saveRcvChatItem user (CDDirectRcv ct'') msg msgMeta (CIRcvDirectEvent RDEContactDeleted)
toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct'') ci)
toView $ CRContactDeletedByContact user ct''
else do
contactConns <- withStore $ \db -> getContactConnections db userId c
deleteAgentConnectionsAsync user $ map aConnId contactConns
withStore' $ \db -> deleteContact db user c
processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact processContactProfileUpdate :: Contact -> Profile -> Bool -> m Contact
processContactProfileUpdate c@Contact {profile = p} p' createItems processContactProfileUpdate c@Contact {profile = p} p' createItems
| fromLocalProfile p /= p' = do | fromLocalProfile p /= p' = do
@ -4937,8 +4957,9 @@ deleteOrUpdateMemberRecord user@User {userId} member =
Nothing -> deleteGroupMember db user member Nothing -> deleteGroupMember db user member
sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64) sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64)
sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent
| connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct | connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct
| contactStatus /= CSActive = throwChatError $ CEContactNotActive ct
| connDisabled conn = throwChatError $ CEContactDisabled ct | connDisabled conn = throwChatError $ CEContactDisabled ct
| otherwise = sendDirectMessage conn chatMsgEvent (ConnectionId connId) | otherwise = sendDirectMessage conn chatMsgEvent (ConnectionId connId)
@ -5400,7 +5421,7 @@ chatCommandP =
"/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP), "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> onOffP <* A.space <*> jsonP),
"/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))),
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
"/_delete " *> (APIDeleteChat <$> chatRefP), "/_delete " *> (APIDeleteChat <$> chatRefP <*> (A.space *> "notify=" *> onOffP <|> pure True)),
"/_clear chat " *> (APIClearChat <$> chatRefP), "/_clear chat " *> (APIClearChat <$> chatRefP),
"/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal), "/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal),
"/_reject " *> (APIRejectContact <$> A.decimal), "/_reject " *> (APIRejectContact <$> A.decimal),

View File

@ -21,7 +21,7 @@ import qualified Data.Text as T
import qualified Database.SQLite3 as SQL import qualified Database.SQLite3 as SQL
import Simplex.Chat.Controller import Simplex.Chat.Controller
import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Client (agentClientStore)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), sqlString, closeSQLiteStore)
import Simplex.Messaging.Util import Simplex.Messaging.Util
import System.FilePath import System.FilePath
import UnliftIO.Directory import UnliftIO.Directory
@ -42,9 +42,9 @@ archiveFilesFolder = "simplex_v1_files"
exportArchive :: ChatMonad m => ArchiveConfig -> m () exportArchive :: ChatMonad m => ArchiveConfig -> m ()
exportArchive cfg@ArchiveConfig {archivePath, disableCompression} = exportArchive cfg@ArchiveConfig {archivePath, disableCompression} =
withTempDir cfg "simplex-chat." $ \dir -> do withTempDir cfg "simplex-chat." $ \dir -> do
StorageFiles {chatDb, agentDb, filesPath} <- storageFiles StorageFiles {chatStore, agentStore, filesPath} <- storageFiles
copyFile chatDb $ dir </> archiveChatDbFile copyFile (dbFilePath chatStore) $ dir </> archiveChatDbFile
copyFile agentDb $ dir </> archiveAgentDbFile copyFile (dbFilePath agentStore) $ dir </> archiveAgentDbFile
forM_ filesPath $ \fp -> forM_ filesPath $ \fp ->
copyDirectoryFiles fp $ dir </> archiveFilesFolder copyDirectoryFiles fp $ dir </> archiveFilesFolder
let method = if disableCompression == Just True then Z.Store else Z.Deflate let method = if disableCompression == Just True then Z.Store else Z.Deflate
@ -54,11 +54,11 @@ importArchive :: ChatMonad m => ArchiveConfig -> m [ArchiveError]
importArchive cfg@ArchiveConfig {archivePath} = importArchive cfg@ArchiveConfig {archivePath} =
withTempDir cfg "simplex-chat." $ \dir -> do withTempDir cfg "simplex-chat." $ \dir -> do
Z.withArchive archivePath $ Z.unpackInto dir Z.withArchive archivePath $ Z.unpackInto dir
StorageFiles {chatDb, agentDb, filesPath} <- storageFiles fs@StorageFiles {chatStore, agentStore, filesPath} <- storageFiles
backup chatDb liftIO $ closeSQLiteStore `withStores` fs
backup agentDb backup `withDBs` fs
copyFile (dir </> archiveChatDbFile) chatDb copyFile (dir </> archiveChatDbFile) $ dbFilePath chatStore
copyFile (dir </> archiveAgentDbFile) agentDb copyFile (dir </> archiveAgentDbFile) $ dbFilePath agentStore
copyFiles dir filesPath copyFiles dir filesPath
`E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e] `E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e]
where where
@ -94,53 +94,60 @@ copyDirectoryFiles fromDir toDir = do
deleteStorage :: ChatMonad m => m () deleteStorage :: ChatMonad m => m ()
deleteStorage = do deleteStorage = do
StorageFiles {chatDb, agentDb, filesPath} <- storageFiles fs <- storageFiles
removeFile chatDb liftIO $ closeSQLiteStore `withStores` fs
removeFile agentDb remove `withDBs` fs
mapM_ removePathForcibly filesPath mapM_ removeDir $ filesPath fs
tmpPath <- readTVarIO =<< asks tempDirectory mapM_ removeDir =<< chatReadVar tempDirectory
mapM_ removePathForcibly tmpPath where
remove f = whenM (doesFileExist f) $ removeFile f
removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d
data StorageFiles = StorageFiles data StorageFiles = StorageFiles
{ chatDb :: FilePath, { chatStore :: SQLiteStore,
chatEncrypted :: TVar Bool, agentStore :: SQLiteStore,
agentDb :: FilePath,
agentEncrypted :: TVar Bool,
filesPath :: Maybe FilePath filesPath :: Maybe FilePath
} }
storageFiles :: ChatMonad m => m StorageFiles storageFiles :: ChatMonad m => m StorageFiles
storageFiles = do storageFiles = do
ChatController {chatStore, filesFolder, smpAgent} <- ask ChatController {chatStore, filesFolder, smpAgent} <- ask
let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore let agentStore = agentClientStore smpAgent
SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentClientStore smpAgent
filesPath <- readTVarIO filesFolder filesPath <- readTVarIO filesFolder
pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath} pure StorageFiles {chatStore, agentStore, filesPath}
sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m () sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m ()
sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} = sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} =
when (key /= key') $ do when (key /= key') $ do
fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles fs <- storageFiles
checkFile `with` fs checkFile `withDBs` fs
backup `with` fs backup `withDBs` fs
(export chatDb chatEncrypted >> export agentDb agentEncrypted) checkEncryption `withStores` fs
`catchChatError` \e -> (restore `with` fs) >> throwError e removeExported `withDBs` fs
export `withDBs` fs
-- closing after encryption prevents closing in case wrong encryption key was passed
liftIO $ closeSQLiteStore `withStores` fs
(moveExported `withStores` fs)
`catchChatError` \e -> (restore `withDBs` fs) >> throwError e
where where
action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb
backup f = copyFile f (f <> ".bak") backup f = copyFile f (f <> ".bak")
restore f = copyFile (f <> ".bak") f restore f = copyFile (f <> ".bak") f
checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f
export f dbEnc = do checkEncryption SQLiteStore {dbEncrypted} = do
enc <- readTVarIO dbEnc enc <- readTVarIO dbEncrypted
when (enc && null key) $ throwDBError DBErrorEncrypted when (enc && null key) $ throwDBError DBErrorEncrypted
when (not enc && not (null key)) $ throwDBError DBErrorPlaintext when (not enc && not (null key)) $ throwDBError DBErrorPlaintext
withDB (`SQL.exec` exportSQL) DBErrorExport exported = (<> ".exported")
renameFile (f <> ".exported") f removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f)
withDB (`SQL.exec` testSQL) DBErrorOpen moveExported SQLiteStore {dbFilePath = f, dbEncrypted} = do
atomically $ writeTVar dbEnc $ not (null key') renameFile (exported f) f
atomically $ writeTVar dbEncrypted $ not (null key')
export f = do
withDB f (`SQL.exec` exportSQL) DBErrorExport
withDB (exported f) (`SQL.exec` testSQL) DBErrorOpen
where where
withDB a err = withDB f' a err =
liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing) liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing)
`catch` checkSQLError `catch` checkSQLError
`catch` (\(e :: SomeException) -> sqliteError' e) `catch` (\(e :: SomeException) -> sqliteError' e)
>>= mapM_ (throwDBError . err) >>= mapM_ (throwDBError . err)
@ -162,7 +169,12 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
keySQL key' keySQL key'
<> [ "PRAGMA foreign_keys = ON;", <> [ "PRAGMA foreign_keys = ON;",
"PRAGMA secure_delete = ON;", "PRAGMA secure_delete = ON;",
"PRAGMA auto_vacuum = FULL;",
"SELECT count(*) FROM sqlite_master;" "SELECT count(*) FROM sqlite_master;"
] ]
keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)] keySQL k = ["PRAGMA key = " <> sqlString k <> ";" | not (null k)]
withDBs :: Monad m => (FilePath -> m b) -> StorageFiles -> m b
action `withDBs` StorageFiles {chatStore, agentStore} = action (dbFilePath chatStore) >> action (dbFilePath agentStore)
withStores :: Monad m => (SQLiteStore -> m b) -> StorageFiles -> m b
action `withStores` StorageFiles {chatStore, agentStore} = action chatStore >> action agentStore

View File

@ -55,9 +55,8 @@ import Simplex.Messaging.Agent.Client (AgentLocks, ProtocolTestFailure)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig) import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig)
import Simplex.Messaging.Agent.Lock import Simplex.Messaging.Agent.Lock
import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration) import Simplex.Messaging.Agent.Store.SQLite (MigrationConfirmation, SQLiteStore, UpMigration, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..)) import Simplex.Messaging.Agent.Store.SQLite.DB (SlowQueryStats (..))
import Simplex.Messaging.Agent.Store.SQLite.Common (withTransaction)
import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB import qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Crypto.File (CryptoFile (..)) import Simplex.Messaging.Crypto.File (CryptoFile (..))
@ -253,7 +252,7 @@ data ChatCommand
| APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction} | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction}
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
| APIChatUnread ChatRef Bool | APIChatUnread ChatRef Bool
| APIDeleteChat ChatRef | APIDeleteChat ChatRef Bool -- `notify` flag is only applied to direct chats
| APIClearChat ChatRef | APIClearChat ChatRef
| APIAcceptContact IncognitoEnabled Int64 | APIAcceptContact IncognitoEnabled Int64
| APIRejectContact Int64 | APIRejectContact Int64
@ -508,6 +507,7 @@ data ChatResponse
| CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact} | CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
| CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact} | CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact}
| CRContactDeleted {user :: User, contact :: Contact} | CRContactDeleted {user :: User, contact :: Contact}
| CRContactDeletedByContact {user :: User, contact :: Contact}
| CRChatCleared {user :: User, chatInfo :: AChatInfo} | CRChatCleared {user :: User, chatInfo :: AChatInfo}
| CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact} | CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact}
| CRUserContactLinkDeleted {user :: User} | CRUserContactLinkDeleted {user :: User}
@ -944,6 +944,7 @@ data ChatErrorType
| CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String}
| CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)} | CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)}
| CEContactNotReady {contact :: Contact} | CEContactNotReady {contact :: Contact}
| CEContactNotActive {contact :: Contact}
| CEContactDisabled {contact :: Contact} | CEContactDisabled {contact :: Contact}
| CEConnectionDisabled {connection :: Connection} | CEConnectionDisabled {connection :: Connection}
| CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole} | CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole}
@ -1060,6 +1061,15 @@ instance ToJSON RemoteCtrlError where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE" toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RCE"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RCE"
data ArchiveError
= AEImport {chatError :: ChatError}
| AEImportFile {file :: String, chatError :: ChatError}
deriving (Show, Exception, Generic)
instance ToJSON ArchiveError where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE"
type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m) type ChatMonad' m = (MonadUnliftIO m, MonadReader ChatController m)
type ChatMonad m = (ChatMonad' m, MonadError ChatError m) type ChatMonad m = (ChatMonad' m, MonadError ChatError m)
@ -1103,15 +1113,6 @@ unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset)
where where
unset a' = if a == a' then ActiveNone else a' unset a' = if a == a' then ActiveNone else a'
data ArchiveError
= AEImport {chatError :: ChatError}
| AEImportFile {file :: String, chatError :: ChatError}
deriving (Show, Exception, Generic)
instance ToJSON ArchiveError where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE"
-- | Emit local events. -- | Emit local events.
toView :: ChatMonad' m => ChatResponse -> m () toView :: ChatMonad' m => ChatResponse -> m ()
toView = toView_ Nothing toView = toView_ Nothing

View File

@ -132,6 +132,7 @@ data CIContent (d :: MsgDirection) where
CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv
CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv
CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd
CIRcvDirectEvent :: RcvDirectEvent -> CIContent 'MDRcv
CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv
CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd
CIRcvConnEvent :: RcvConnEvent -> CIContent 'MDRcv CIRcvConnEvent :: RcvConnEvent -> CIContent 'MDRcv
@ -179,6 +180,7 @@ ciRequiresAttention content = case msgDirection @d of
CIRcvIntegrityError _ -> True CIRcvIntegrityError _ -> True
CIRcvDecryptionError {} -> True CIRcvDecryptionError {} -> True
CIRcvGroupInvitation {} -> True CIRcvGroupInvitation {} -> True
CIRcvDirectEvent _ -> False
CIRcvGroupEvent rge -> case rge of CIRcvGroupEvent rge -> case rge of
RGEMemberAdded {} -> False RGEMemberAdded {} -> False
RGEMemberConnected -> False RGEMemberConnected -> False
@ -300,6 +302,27 @@ instance ToJSON DBSndConnEvent where
toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v toJSON (SCE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "SCE") v
toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v toEncoding (SCE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "SCE") v
data RcvDirectEvent =
-- RDEProfileChanged {...}
RDEContactDeleted
deriving (Show, Generic)
instance FromJSON RcvDirectEvent where
parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "RDE"
instance ToJSON RcvDirectEvent where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "RDE"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "RDE"
newtype DBRcvDirectEvent = RDE RcvDirectEvent
instance FromJSON DBRcvDirectEvent where
parseJSON v = RDE <$> J.genericParseJSON (singleFieldJSON $ dropPrefix "RDE") v
instance ToJSON DBRcvDirectEvent where
toJSON (RDE v) = J.genericToJSON (singleFieldJSON $ dropPrefix "RDE") v
toEncoding (RDE v) = J.genericToEncoding (singleFieldJSON $ dropPrefix "RDE") v
newtype DBMsgErrorType = DBME MsgErrorType newtype DBMsgErrorType = DBME MsgErrorType
instance FromJSON DBMsgErrorType where instance FromJSON DBMsgErrorType where
@ -348,6 +371,7 @@ ciContentToText = \case
CIRcvDecryptionError err n -> msgDecryptErrorText err n CIRcvDecryptionError err n -> msgDecryptErrorText err n
CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole
CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole
CIRcvDirectEvent event -> rcvDirectEventToText event
CIRcvGroupEvent event -> rcvGroupEventToText event CIRcvGroupEvent event -> rcvGroupEventToText event
CISndGroupEvent event -> sndGroupEventToText event CISndGroupEvent event -> sndGroupEventToText event
CIRcvConnEvent event -> rcvConnEventToText event CIRcvConnEvent event -> rcvConnEventToText event
@ -368,6 +392,10 @@ ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text
ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role = ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} role =
"invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role) "invitation to join group " <> displayName <> optionalFullName displayName fullName <> " as " <> (decodeLatin1 . strEncode $ role)
rcvDirectEventToText :: RcvDirectEvent -> Text
rcvDirectEventToText = \case
RDEContactDeleted -> "contact deleted"
rcvGroupEventToText :: RcvGroupEvent -> Text rcvGroupEventToText :: RcvGroupEvent -> Text
rcvGroupEventToText = \case rcvGroupEventToText = \case
RGEMemberAdded _ p -> "added " <> profileToText p RGEMemberAdded _ p -> "added " <> profileToText p
@ -486,6 +514,7 @@ data JSONCIContent
| JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32}
| JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| JCIRcvDirectEvent {rcvDirectEvent :: RcvDirectEvent}
| JCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent} | JCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent}
| JCISndGroupEvent {sndGroupEvent :: SndGroupEvent} | JCISndGroupEvent {sndGroupEvent :: SndGroupEvent}
| JCIRcvConnEvent {rcvConnEvent :: RcvConnEvent} | JCIRcvConnEvent {rcvConnEvent :: RcvConnEvent}
@ -522,6 +551,7 @@ jsonCIContent = \case
CIRcvDecryptionError err n -> JCIRcvDecryptionError err n CIRcvDecryptionError err n -> JCIRcvDecryptionError err n
CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole} CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole}
CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole}
CIRcvDirectEvent rcvDirectEvent -> JCIRcvDirectEvent {rcvDirectEvent}
CIRcvGroupEvent rcvGroupEvent -> JCIRcvGroupEvent {rcvGroupEvent} CIRcvGroupEvent rcvGroupEvent -> JCIRcvGroupEvent {rcvGroupEvent}
CISndGroupEvent sndGroupEvent -> JCISndGroupEvent {sndGroupEvent} CISndGroupEvent sndGroupEvent -> JCISndGroupEvent {sndGroupEvent}
CIRcvConnEvent rcvConnEvent -> JCIRcvConnEvent {rcvConnEvent} CIRcvConnEvent rcvConnEvent -> JCIRcvConnEvent {rcvConnEvent}
@ -550,6 +580,7 @@ aciContentJSON = \case
JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n
JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole
JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole
JCIRcvDirectEvent {rcvDirectEvent} -> ACIContent SMDRcv $ CIRcvDirectEvent rcvDirectEvent
JCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent JCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent
JCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent JCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent
JCIRcvConnEvent {rcvConnEvent} -> ACIContent SMDRcv $ CIRcvConnEvent rcvConnEvent JCIRcvConnEvent {rcvConnEvent} -> ACIContent SMDRcv $ CIRcvConnEvent rcvConnEvent
@ -579,6 +610,7 @@ data DBJSONCIContent
| DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32} | DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32}
| DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole} | DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
| DBJCIRcvDirectEvent {rcvDirectEvent :: DBRcvDirectEvent}
| DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent} | DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent}
| DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent} | DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent}
| DBJCIRcvConnEvent {rcvConnEvent :: DBRcvConnEvent} | DBJCIRcvConnEvent {rcvConnEvent :: DBRcvConnEvent}
@ -615,6 +647,7 @@ dbJsonCIContent = \case
CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n
CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole} CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole}
CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole} CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole}
CIRcvDirectEvent rde -> DBJCIRcvDirectEvent $ RDE rde
CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge
CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge
CIRcvConnEvent rce -> DBJCIRcvConnEvent $ RCE rce CIRcvConnEvent rce -> DBJCIRcvConnEvent $ RCE rce
@ -643,6 +676,7 @@ aciContentDBJSON = \case
DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n
DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole
DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole
DBJCIRcvDirectEvent (RDE rde) -> ACIContent SMDRcv $ CIRcvDirectEvent rde
DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge
DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge
DBJCIRcvConnEvent (RCE rce) -> ACIContent SMDRcv $ CIRcvConnEvent rce DBJCIRcvConnEvent (RCE rce) -> ACIContent SMDRcv $ CIRcvConnEvent rce

View File

@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20230926_contact_status where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20230926_contact_status :: Query
m20230926_contact_status =
[sql|
ALTER TABLE contacts ADD COLUMN contact_status TEXT NOT NULL DEFAULT 'active';
|]
down_m20230926_contact_status :: Query
down_m20230926_contact_status =
[sql|
ALTER TABLE contacts DROP COLUMN contact_status;
|]

View File

@ -71,6 +71,7 @@ CREATE TABLE contacts(
contact_group_member_id INTEGER contact_group_member_id INTEGER
REFERENCES group_members(group_member_id) ON DELETE SET NULL, REFERENCES group_members(group_member_id) ON DELETE SET NULL,
contact_grp_inv_sent INTEGER NOT NULL DEFAULT 0, contact_grp_inv_sent INTEGER NOT NULL DEFAULT 0,
contact_status TEXT NOT NULL DEFAULT 'active',
FOREIGN KEY(user_id, local_display_name) FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE ON DELETE CASCADE

View File

@ -215,6 +215,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json
XInfo :: Profile -> ChatMsgEvent 'Json XInfo :: Profile -> ChatMsgEvent 'Json
XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json
XDirectDel :: ChatMsgEvent 'Json
XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json
XGrpAcpt :: MemberId -> ChatMsgEvent 'Json XGrpAcpt :: MemberId -> ChatMsgEvent 'Json
XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json
@ -550,6 +551,7 @@ data CMEventTag (e :: MsgEncoding) where
XFileCancel_ :: CMEventTag 'Json XFileCancel_ :: CMEventTag 'Json
XInfo_ :: CMEventTag 'Json XInfo_ :: CMEventTag 'Json
XContact_ :: CMEventTag 'Json XContact_ :: CMEventTag 'Json
XDirectDel_ :: CMEventTag 'Json
XGrpInv_ :: CMEventTag 'Json XGrpInv_ :: CMEventTag 'Json
XGrpAcpt_ :: CMEventTag 'Json XGrpAcpt_ :: CMEventTag 'Json
XGrpMemNew_ :: CMEventTag 'Json XGrpMemNew_ :: CMEventTag 'Json
@ -596,6 +598,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where
XFileCancel_ -> "x.file.cancel" XFileCancel_ -> "x.file.cancel"
XInfo_ -> "x.info" XInfo_ -> "x.info"
XContact_ -> "x.contact" XContact_ -> "x.contact"
XDirectDel_ -> "x.direct.del"
XGrpInv_ -> "x.grp.inv" XGrpInv_ -> "x.grp.inv"
XGrpAcpt_ -> "x.grp.acpt" XGrpAcpt_ -> "x.grp.acpt"
XGrpMemNew_ -> "x.grp.mem.new" XGrpMemNew_ -> "x.grp.mem.new"
@ -643,6 +646,7 @@ instance StrEncoding ACMEventTag where
"x.file.cancel" -> XFileCancel_ "x.file.cancel" -> XFileCancel_
"x.info" -> XInfo_ "x.info" -> XInfo_
"x.contact" -> XContact_ "x.contact" -> XContact_
"x.direct.del" -> XDirectDel_
"x.grp.inv" -> XGrpInv_ "x.grp.inv" -> XGrpInv_
"x.grp.acpt" -> XGrpAcpt_ "x.grp.acpt" -> XGrpAcpt_
"x.grp.mem.new" -> XGrpMemNew_ "x.grp.mem.new" -> XGrpMemNew_
@ -686,6 +690,7 @@ toCMEventTag msg = case msg of
XFileCancel _ -> XFileCancel_ XFileCancel _ -> XFileCancel_
XInfo _ -> XInfo_ XInfo _ -> XInfo_
XContact _ _ -> XContact_ XContact _ _ -> XContact_
XDirectDel -> XDirectDel_
XGrpInv _ -> XGrpInv_ XGrpInv _ -> XGrpInv_
XGrpAcpt _ -> XGrpAcpt_ XGrpAcpt _ -> XGrpAcpt_
XGrpMemNew _ -> XGrpMemNew_ XGrpMemNew _ -> XGrpMemNew_
@ -782,6 +787,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
XFileCancel_ -> XFileCancel <$> p "msgId" XFileCancel_ -> XFileCancel <$> p "msgId"
XInfo_ -> XInfo <$> p "profile" XInfo_ -> XInfo <$> p "profile"
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
XDirectDel_ -> pure XDirectDel
XGrpInv_ -> XGrpInv <$> p "groupInvitation" XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId" XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo" XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
@ -839,6 +845,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @
XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId] XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId]
XInfo profile -> o ["profile" .= profile] XInfo profile -> o ["profile" .= profile]
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
XDirectDel -> JM.empty
XGrpInv groupInv -> o ["groupInvitation" .= groupInv] XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId -> o ["memberId" .= memId] XGrpAcpt memId -> o ["memberId" .= memId]
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo] XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]

View File

@ -71,19 +71,19 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
db db
[sql| [sql|
SELECT SELECT
c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.enable_ntfs, c.send_rcpts, c.favorite, c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, c.via_group, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite,
p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.contact_group_member_id, c.contact_grp_inv_sent
FROM contacts c FROM contacts c
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0 WHERE c.user_id = ? AND c.contact_id = ? AND c.deleted = 0
|] |]
(userId, contactId) (userId, contactId)
toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)] -> Either StoreError Contact toContact' :: Int64 -> Connection -> [(ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, 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) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] = toContact' contactId activeConn [(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} let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} 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" toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember) getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
getGroupAndMember_ groupMemberId c = ExceptT $ do getGroupAndMember_ groupMemberId c = ExceptT $ do

View File

@ -42,6 +42,7 @@ module Simplex.Chat.Store.Direct
deletePCCIncognitoProfile, deletePCCIncognitoProfile,
updateContactUsed, updateContactUsed,
updateContactUnreadChat, updateContactUnreadChat,
updateContactStatus,
updateGroupUnreadChat, updateGroupUnreadChat,
setConnectionVerified, setConnectionVerified,
incConnectionAuthErrCounter, incConnectionAuthErrCounter,
@ -147,7 +148,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do
[sql| [sql|
SELECT SELECT
-- Contact -- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent,
-- Connection -- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
@ -206,7 +207,7 @@ createDirectContact db user@User {userId} activeConn@Connection {connId, localAl
let profile = toLocalProfile profileId p localAlias let profile = toLocalProfile profileId p localAlias
userPreferences = emptyChatPrefs userPreferences = emptyChatPrefs
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} 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}
deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO () deleteContactConnectionsAndFiles :: DB.Connection -> UserId -> Contact -> IO ()
deleteContactConnectionsAndFiles db userId Contact {contactId} = do deleteContactConnectionsAndFiles db userId Contact {contactId} = do
@ -387,6 +388,19 @@ updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do
updatedAt <- getCurrentTime updatedAt <- getCurrentTime
DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId) DB.execute db "UPDATE contacts SET unread_chat = ?, updated_at = ? WHERE user_id = ? AND contact_id = ?" (unreadChat, updatedAt, userId, contactId)
updateContactStatus :: DB.Connection -> User -> Contact -> ContactStatus -> IO Contact
updateContactStatus db User {userId} ct@Contact {contactId} contactStatus = do
currentTs <- getCurrentTime
DB.execute
db
[sql|
UPDATE contacts
SET contact_status = ?, updated_at = ?
WHERE user_id = ? AND contact_id = ?
|]
(contactStatus, currentTs, userId, contactId)
pure ct {contactStatus}
updateGroupUnreadChat :: DB.Connection -> User -> GroupInfo -> Bool -> IO () updateGroupUnreadChat :: DB.Connection -> User -> GroupInfo -> Bool -> IO ()
updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do updateGroupUnreadChat db User {userId} GroupInfo {groupId} unreadChat = do
updatedAt <- getCurrentTime updatedAt <- getCurrentTime
@ -491,7 +505,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers
[sql| [sql|
SELECT SELECT
-- Contact -- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent,
-- Connection -- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
@ -637,7 +651,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
contactId <- insertedRowId db contactId <- insertedRowId db
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = createdAt, updatedAt = createdAt, chatTs = Just createdAt, contactGroupMemberId = Nothing, contactGrpInvSent = False} 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}
getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64 getContactIdByName :: DB.Connection -> User -> ContactName -> ExceptT StoreError IO Int64
getContactIdByName db User {userId} cName = getContactIdByName db User {userId} cName =
@ -655,7 +669,7 @@ getContact_ db user@User {userId} contactId deleted =
[sql| [sql|
SELECT SELECT
-- Contact -- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent,
-- Connection -- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,

View File

@ -700,7 +700,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} =
[sql| [sql|
SELECT SELECT
-- Contact -- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent,
-- Connection -- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,
@ -1044,7 +1044,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
db db
[sql| [sql|
SELECT SELECT
ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, ct.contact_id, ct.contact_profile_id, ct.local_display_name, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, ct.via_group, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, p.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id,
c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter, c.conn_status, c.conn_type, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.auth_err_counter,
@ -1062,13 +1062,13 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
|] |]
(userId, groupMemberId) (userId, groupMemberId)
where where
toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact toContact' :: ((ContactId, ProfileId, ContactName, Text, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Maybe Int64, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)) :. ConnectionRow -> Contact
toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = toContact' (((contactId, profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
activeConn = toConnection connRow activeConn = toConnection connRow
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent}
updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo
updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences} updateGroupProfile db User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName}} p'@GroupProfile {displayName = newName, fullName, description, image, groupPreferences}
@ -1160,8 +1160,8 @@ getMatchingContacts :: DB.Connection -> User -> Contact -> IO [Contact]
getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalProfile {displayName, fullName, image}} = do
contactIds <- contactIds <-
map fromOnly <$> case image of map fromOnly <$> case image of
Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, displayName, fullName, img) Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, CSActive, displayName, fullName, img)
Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, displayName, fullName) Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, CSActive, displayName, fullName)
rights <$> mapM (runExceptT . getContact db user) contactIds rights <$> mapM (runExceptT . getContact db user) contactIds
where where
-- this query is different from one in getMatchingMemberContacts -- this query is different from one in getMatchingMemberContacts
@ -1172,7 +1172,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro
FROM contacts ct FROM contacts ct
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
WHERE ct.user_id = ? AND ct.contact_id != ? WHERE ct.user_id = ? AND ct.contact_id != ?
AND ct.deleted = 0 AND ct.contact_status = ? AND ct.deleted = 0
AND p.display_name = ? AND p.full_name = ? AND p.display_name = ? AND p.full_name = ?
|] |]
@ -1521,7 +1521,7 @@ createMemberContact
connId <- insertedRowId db connId <- insertedRowId db
let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, entityId = Just contactId, viaContact = Nothing, viaUserContactLink = Nothing, viaGroupLink = False, groupLinkId = Nothing, customUserProfileId, connLevel, connStatus = ConnNew, localAlias = "", createdAt = currentTs, connectionCode = Nothing, authErrCounter = 0} let ctConn = Connection {connId, agentConnId = AgentConnId acId, peerChatVRange, connType = ConnContact, 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 mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False} 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}
getMemberContact :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact :: DB.Connection -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation)
getMemberContact db user contactId = do getMemberContact db user contactId = do
@ -1558,7 +1558,7 @@ createMemberContactInvited
contactId <- createContactUpdateMember currentTs userPreferences contactId <- createContactUpdateMember currentTs userPreferences
ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Nothing, contactGrpInvSent = False} 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}
m' = m {memberContactId = Just contactId} m' = m {memberContactId = Just contactId}
pure (mCt', m') pure (mCt', m')
where where
@ -1586,8 +1586,9 @@ updateMemberContactInvited :: DB.Connection -> User -> (CommandId, ConnId) -> Gr
updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = oldContactConn} subMode = do updateMemberContactInvited db user connIds gInfo mConn ct@Contact {contactId, activeConn = oldContactConn} subMode = do
updateConnectionStatus db oldContactConn ConnDeleted updateConnectionStatus db oldContactConn ConnDeleted
activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
ct' <- resetMemberContactFields db ct ct' <- updateContactStatus db user ct CSActive
pure (ct' :: Contact) {activeConn} ct'' <- resetMemberContactFields db ct'
pure (ct'' :: Contact) {activeConn}
resetMemberContactFields :: DB.Connection -> Contact -> IO Contact resetMemberContactFields :: DB.Connection -> Contact -> IO Contact
resetMemberContactFields db ct@Contact {contactId} = do resetMemberContactFields db ct@Contact {contactId} = do

View File

@ -478,7 +478,7 @@ getDirectChatPreviews_ db user@User {userId} = do
[sql| [sql|
SELECT SELECT
-- Contact -- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.enable_ntfs, ct.send_rcpts, ct.favorite, ct.contact_id, ct.contact_profile_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, cp.contact_link, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent, cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.contact_group_member_id, ct.contact_grp_inv_sent,
-- Connection -- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.local_alias,

View File

@ -82,6 +82,7 @@ import Simplex.Chat.Migrations.M20230903_connections_to_subscribe
import Simplex.Chat.Migrations.M20230913_member_contacts import Simplex.Chat.Migrations.M20230913_member_contacts
import Simplex.Chat.Migrations.M20230914_member_probes import Simplex.Chat.Migrations.M20230914_member_probes
import Simplex.Chat.Migrations.M20230922_remote_controller import Simplex.Chat.Migrations.M20230922_remote_controller
import Simplex.Chat.Migrations.M20230926_contact_status
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)] schemaMigrations :: [(String, Query, Maybe Query)]
@ -163,7 +164,8 @@ schemaMigrations =
("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe), ("20230903_connections_to_subscribe", m20230903_connections_to_subscribe, Just down_m20230903_connections_to_subscribe),
("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts), ("20230913_member_contacts", m20230913_member_contacts, Just down_m20230913_member_contacts),
("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes), ("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes),
("20230922_remote_controller", m20230922_remote_controller, Just down_m20230922_remote_controller) ("20230922_remote_controller", m20230922_remote_controller, Just down_m20230922_remote_controller),
("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status)
] ]
-- | The list of migrations in ascending order by date -- | The list of migrations in ascending order by date

View File

@ -241,24 +241,24 @@ deleteUnusedIncognitoProfileById_ db User {userId} profileId =
|] |]
[":user_id" := userId, ":profile_id" := profileId] [":user_id" := userId, ":profile_id" := profileId]
type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool) type ContactRow = (ContactId, ProfileId, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, Maybe ConnReqContact, LocalAlias, Bool, ContactStatus) :. (Maybe Bool, Maybe Bool, Bool, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime, Maybe GroupMemberId, Bool)
toContact :: User -> ContactRow :. ConnectionRow -> Contact toContact :: User -> ContactRow :. ConnectionRow -> Contact
toContact user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = 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} let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
activeConn = toConnection connRow activeConn = toConnection connRow
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} 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 -> ContactRow :. MaybeConnectionRow -> Either StoreError Contact
toContactOrError user (((contactId, profileId, localDisplayName, viaGroup, displayName, fullName, image, contactLink, localAlias, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) = 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} let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite} chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
in case toMaybeConnection connRow of in case toMaybeConnection connRow of
Just activeConn -> Just activeConn ->
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent} in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent}
_ -> Left $ SEContactNotReady localDisplayName _ -> Left $ SEContactNotReady localDisplayName
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile

View File

@ -169,6 +169,7 @@ data Contact = Contact
activeConn :: Connection, activeConn :: Connection,
viaGroup :: Maybe Int64, viaGroup :: Maybe Int64,
contactUsed :: Bool, contactUsed :: Bool,
contactStatus :: ContactStatus,
chatSettings :: ChatSettings, chatSettings :: ChatSettings,
userPreferences :: Preferences, userPreferences :: Preferences,
mergedPreferences :: ContactUserPreferences, mergedPreferences :: ContactUserPreferences,
@ -185,7 +186,7 @@ instance ToJSON Contact where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
contactConn :: Contact -> Connection contactConn :: Contact -> Connection
contactConn Contact{activeConn} = activeConn contactConn Contact {activeConn} = activeConn
contactConnId :: Contact -> ConnId contactConnId :: Contact -> ConnId
contactConnId = aConnId . contactConn contactConnId = aConnId . contactConn
@ -205,9 +206,34 @@ directOrUsed ct@Contact {contactUsed} =
anyDirectOrUsed :: Contact -> Bool anyDirectOrUsed :: Contact -> Bool
anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed
contactActive :: Contact -> Bool
contactActive Contact {contactStatus} = contactStatus == CSActive
contactSecurityCode :: Contact -> Maybe SecurityCode contactSecurityCode :: Contact -> Maybe SecurityCode
contactSecurityCode Contact {activeConn} = connectionCode activeConn contactSecurityCode Contact {activeConn} = connectionCode activeConn
data ContactStatus
= CSActive
| CSDeleted -- contact deleted by contact
deriving (Eq, Show, Ord)
instance FromField ContactStatus where fromField = fromTextField_ textDecode
instance ToField ContactStatus where toField = toField . textEncode
instance ToJSON ContactStatus where
toJSON = J.String . textEncode
toEncoding = JE.text . textEncode
instance TextEncoding ContactStatus where
textDecode = \case
"active" -> Just CSActive
"deleted" -> Just CSDeleted
_ -> Nothing
textEncode = \case
CSActive -> "active"
CSDeleted -> "deleted"
data ContactRef = ContactRef data ContactRef = ContactRef
{ contactId :: ContactId, { contactId :: ContactId,
connId :: Int64, connId :: Int64,

View File

@ -151,6 +151,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
CRSentConfirmation u -> ttyUser u ["confirmation sent!"] CRSentConfirmation u -> ttyUser u ["confirmation sent!"]
CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"]
CRContactDeletedByContact u c -> ttyUser u [ttyFullContact c <> " deleted contact with you"]
CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo
CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."] CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."]
CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"] CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"]
@ -1568,6 +1569,7 @@ viewChatError logLevel = \case
] ]
CEContactNotFound cName m_ -> viewContactNotFound cName m_ CEContactNotFound cName m_ -> viewContactNotFound cName m_
CEContactNotReady c -> [ttyContact' c <> ": not ready"] CEContactNotReady c -> [ttyContact' c <> ": not ready"]
CEContactNotActive c -> [ttyContact' c <> ": not active"]
CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)]
CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning] CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning]
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"] CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]

View File

@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561 # - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq # - ../simplexmq
- github: simplex-chat/simplexmq - github: simplex-chat/simplexmq
commit: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5 commit: ec1b72cb8013a65a5d9783104a47ae44f5730089
- github: kazu-yamamoto/http2 - github: kazu-yamamoto/http2
commit: b5a1b7200cf5bc7044af34ba325284271f6dff25 commit: b5a1b7200cf5bc7044af34ba325284271f6dff25
# - ../direct-sqlcipher # - ../direct-sqlcipher

View File

@ -31,6 +31,7 @@ chatDirectTests = do
describe "direct messages" $ do describe "direct messages" $ do
describe "add contact and send/receive message" testAddContact describe "add contact and send/receive message" testAddContact
it "deleting contact deletes profile" testDeleteContactDeletesProfile it "deleting contact deletes profile" testDeleteContactDeletesProfile
it "unused contact is deleted silently" testDeleteUnusedContactSilent
it "direct message quoted replies" testDirectMessageQuotedReply it "direct message quoted replies" testDirectMessageQuotedReply
it "direct message update" testDirectMessageUpdate it "direct message update" testDirectMessageUpdate
it "direct message edit history" testDirectMessageEditHistory it "direct message edit history" testDirectMessageEditHistory
@ -156,11 +157,12 @@ testAddContact = versionTestMatrix2 runTestAddContact
-- test deleting contact -- test deleting contact
alice ##> "/d bob_1" alice ##> "/d bob_1"
alice <## "bob_1: contact is deleted" alice <## "bob_1: contact is deleted"
bob <## "alice_1 (Alice) deleted contact with you"
alice ##> "@bob_1 hey" alice ##> "@bob_1 hey"
alice <## "no contact bob_1" alice <## "no contact bob_1"
alice @@@ [("@bob", "how are you?")] alice @@@ [("@bob", "how are you?")]
alice `hasContactProfiles` ["alice", "bob"] alice `hasContactProfiles` ["alice", "bob"]
bob @@@ [("@alice_1", "hi"), ("@alice", "how are you?")] bob @@@ [("@alice_1", "contact deleted"), ("@alice", "how are you?")]
bob `hasContactProfiles` ["alice", "alice", "bob"] bob `hasContactProfiles` ["alice", "alice", "bob"]
-- test clearing chat -- test clearing chat
alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY") alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY")
@ -202,6 +204,7 @@ testDeleteContactDeletesProfile =
-- alice deletes contact, profile is deleted -- alice deletes contact, profile is deleted
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## "alice (Alice) deleted contact with you"
alice ##> "/_contacts 1" alice ##> "/_contacts 1"
(alice </) (alice </)
alice `hasContactProfiles` ["alice"] alice `hasContactProfiles` ["alice"]
@ -212,6 +215,42 @@ testDeleteContactDeletesProfile =
(bob </) (bob </)
bob `hasContactProfiles` ["bob"] bob `hasContactProfiles` ["bob"]
testDeleteUnusedContactSilent :: HasCallStack => FilePath -> IO ()
testDeleteUnusedContactSilent =
testChatCfg3 testCfgCreateGroupDirect aliceProfile bobProfile cathProfile $
\alice bob cath -> do
createGroup3 "team" alice bob cath
bob ##> "/contacts"
bob <### ["alice (Alice)", "cath (Catherine)"]
bob `hasContactProfiles` ["bob", "alice", "cath"]
cath ##> "/contacts"
cath <### ["alice (Alice)", "bob (Bob)"]
cath `hasContactProfiles` ["cath", "alice", "bob"]
-- bob deletes cath, cath's bob contact is deleted silently
bob ##> "/d cath"
bob <## "cath: contact is deleted"
bob ##> "/contacts"
bob <## "alice (Alice)"
threadDelay 50000
cath ##> "/contacts"
cath <## "alice (Alice)"
-- group messages work
alice #> "#team hello"
concurrentlyN_
[ bob <# "#team alice> hello",
cath <# "#team alice> hello"
]
bob #> "#team hi there"
concurrentlyN_
[ alice <# "#team bob> hi there",
cath <# "#team bob> hi there"
]
cath #> "#team hey"
concurrentlyN_
[ alice <# "#team cath> hey",
bob <# "#team cath> hey"
]
testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO () testDirectMessageQuotedReply :: HasCallStack => FilePath -> IO ()
testDirectMessageQuotedReply = testDirectMessageQuotedReply =
testChat2 aliceProfile bobProfile $ testChat2 aliceProfile bobProfile $
@ -514,7 +553,7 @@ testRepeatAuthErrorsDisableContact =
connectUsers alice bob connectUsers alice bob
alice <##> bob alice <##> bob
threadDelay 500000 threadDelay 500000
bob ##> "/d alice" bob ##> "/_delete @2 notify=off"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
forM_ [1 .. authErrDisableCount] $ \_ -> sendAuth alice forM_ [1 .. authErrDisableCount] $ \_ -> sendAuth alice
alice <## "[bob] connection is disabled, to enable: /enable bob, to delete: /d bob" alice <## "[bob] connection is disabled, to enable: /enable bob, to delete: /d bob"

View File

@ -575,6 +575,7 @@ testSendImage =
-- deleting contact without files folder set should not remove file -- deleting contact without files folder set should not remove file
bob ##> "/d alice" bob ##> "/d alice"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
alice <## "bob (Bob) deleted contact with you"
fileExists <- doesFileExist "./tests/tmp/test.jpg" fileExists <- doesFileExist "./tests/tmp/test.jpg"
fileExists `shouldBe` True fileExists `shouldBe` True
@ -637,6 +638,7 @@ testFilesFoldersSendImage =
checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do
bob ##> "/d alice" bob ##> "/d alice"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
alice <## "bob (Bob) deleted contact with you"
testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO () testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO ()
testFilesFoldersImageSndDelete = testFilesFoldersImageSndDelete =
@ -660,6 +662,7 @@ testFilesFoldersImageSndDelete =
checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## "alice (Alice) deleted contact with you"
bob ##> "/fs 1" bob ##> "/fs 1"
bob <##. "receiving file 1 (test_1MB.pdf) progress" bob <##. "receiving file 1 (test_1MB.pdf) progress"
-- deleting contact should remove cancelled file -- deleting contact should remove cancelled file
@ -689,7 +692,10 @@ testFilesFoldersImageRcvDelete =
checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do
bob ##> "/d alice" bob ##> "/d alice"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
alice <## "bob cancelled receiving file 1 (test.jpg)" alice
<### [ "bob (Bob) deleted contact with you",
"bob cancelled receiving file 1 (test.jpg)"
]
alice ##> "/fs 1" alice ##> "/fs 1"
alice <## "sending file 1 (test.jpg) cancelled: bob" alice <## "sending file 1 (test.jpg) cancelled: bob"
alice <## "file transfer cancelled" alice <## "file transfer cancelled"

View File

@ -220,6 +220,7 @@ testGroupShared alice bob cath checkMessages = do
-- delete contact -- delete contact
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## "alice (Alice) deleted contact with you"
alice `send` "@bob hey" alice `send` "@bob hey"
alice alice
<### [ "@bob hey", <### [ "@bob hey",
@ -234,7 +235,7 @@ testGroupShared alice bob cath checkMessages = do
alice <# "#team bob> received" alice <# "#team bob> received"
when checkMessages $ do when checkMessages $ do
alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")] alice @@@ [("@cath", "sent invitation to join group team as admin"), ("#team", "received")]
bob @@@ [("@alice", "received invitation to join group team as admin"), ("@cath", "hey"), ("#team", "received")] bob @@@ [("@alice", "contact deleted"), ("@cath", "hey"), ("#team", "received")]
-- test clearing chat -- test clearing chat
threadDelay 1000000 threadDelay 1000000
alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY") alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
@ -629,6 +630,7 @@ testGroupDeleteInvitedContact =
threadDelay 500000 threadDelay 500000
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## "alice (Alice) deleted contact with you"
bob ##> "/j team" bob ##> "/j team"
concurrently_ concurrently_
(alice <## "#team: bob joined the group") (alice <## "#team: bob joined the group")
@ -700,10 +702,11 @@ testDeleteGroupMemberProfileKept =
-- delete contact -- delete contact
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## "alice (Alice) deleted contact with you"
alice ##> "@bob hey" alice ##> "@bob hey"
alice <## "no contact bob, use @#club bob <your message>" alice <## "no contact bob, use @#club bob <your message>"
bob #> "@alice hey" bob ##> "@alice hey"
bob <## "[alice, contactId: 2, connId: 1] error: connection authorization failed - this could happen if connection was deleted, secured with different credentials, or due to a bug - please re-create the connection" bob <## "alice: not ready"
(alice </) (alice </)
-- delete group 1 -- delete group 1
alice ##> "/d #team" alice ##> "/d #team"
@ -2785,6 +2788,8 @@ testMemberContactMessage =
-- alice and bob delete contacts, connect -- alice and bob delete contacts, connect
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## "alice (Alice) deleted contact with you"
bob ##> "/d alice" bob ##> "/d alice"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
@ -2893,6 +2898,7 @@ testMemberContactInvitedConnectionReplaced tmp = do
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## "alice (Alice) deleted contact with you"
alice ##> "@#team bob hi" alice ##> "@#team bob hi"
alice alice
@ -2910,7 +2916,7 @@ testMemberContactInvitedConnectionReplaced tmp = do
(alice <## "bob (Bob): contact is connected") (alice <## "bob (Bob): contact is connected")
(bob <## "alice (Alice): contact is connected") (bob <## "alice (Alice): contact is connected")
bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "hi"), (0, "security code changed")] <> chatFeatures) bob #$> ("/_get chat @2 count=100", chat, chatFeatures <> [(0, "received invitation to join group team as admin"), (0, "contact deleted"), (0, "hi"), (0, "security code changed")] <> chatFeatures)
withTestChat tmp "bob" $ \bob -> do withTestChat tmp "bob" $ \bob -> do
subscriptions bob 1 subscriptions bob 1

View File

@ -558,6 +558,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi
-- alice deletes contact, incognito profile is deleted -- alice deletes contact, incognito profile is deleted
alice ##> ("/d " <> bobIncognito) alice ##> ("/d " <> bobIncognito)
alice <## (bobIncognito <> ": contact is deleted") alice <## (bobIncognito <> ": contact is deleted")
bob <## (aliceIncognito <> " deleted contact with you")
alice ##> "/contacts" alice ##> "/contacts"
alice <## "cath (Catherine)" alice <## "cath (Catherine)"
alice `hasContactProfiles` ["alice", "cath"] alice `hasContactProfiles` ["alice", "cath"]
@ -601,6 +602,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $
-- delete contact, incognito profile is deleted -- delete contact, incognito profile is deleted
bob ##> "/d alice" bob ##> "/d alice"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
alice <## (bobIncognito <> " deleted contact with you")
bob ##> "/contacts" bob ##> "/contacts"
(bob </) (bob </)
bob `hasContactProfiles` ["bob"] bob `hasContactProfiles` ["bob"]
@ -633,6 +635,7 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil
-- delete contact, incognito profile is deleted -- delete contact, incognito profile is deleted
alice ##> "/d bob" alice ##> "/d bob"
alice <## "bob: contact is deleted" alice <## "bob: contact is deleted"
bob <## (aliceIncognitoBob <> " deleted contact with you")
alice ##> "/contacts" alice ##> "/contacts"
(alice </) (alice </)
alice `hasContactProfiles` ["alice"] alice `hasContactProfiles` ["alice"]
@ -1063,6 +1066,7 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr
-- delete contact -- delete contact
bob ##> "/d alice" bob ##> "/d alice"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
alice <## (bobIncognito <> " deleted contact with you")
bob ##> "/contacts" bob ##> "/contacts"
(bob </) (bob </)
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito] bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
@ -1125,6 +1129,7 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr
-- delete contact -- delete contact
bob ##> "/d alice" bob ##> "/d alice"
bob <## "alice: contact is deleted" bob <## "alice: contact is deleted"
alice <## (bobIncognito <> " deleted contact with you")
bob ##> "/contacts" bob ##> "/contacts"
(bob </) (bob </)
bob `hasContactProfiles` ["bob"] bob `hasContactProfiles` ["bob"]