Compare commits
59 Commits
v5.3.0
...
pdavidow-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef02b27bca | ||
|
|
7c73a44a51 | ||
|
|
b3e9c7f7dc | ||
|
|
77db70139b | ||
|
|
fdf3da73aa | ||
|
|
0d93dab692 | ||
|
|
d4cbef1ba1 | ||
|
|
8545a1e8f9 | ||
|
|
157ea59ebb | ||
|
|
7231201c3c | ||
|
|
695d47da2d | ||
|
|
968d8e9c34 | ||
|
|
d72c9a6de0 | ||
|
|
5ce388522e | ||
|
|
70a65e8969 | ||
|
|
1d34500fba | ||
|
|
bc7baf560b | ||
|
|
c1854b7d50 | ||
|
|
682dfe503c | ||
|
|
957f3b3eb0 | ||
|
|
dea96df27b | ||
|
|
942e5eb8c4 | ||
|
|
ea319313f1 | ||
|
|
bbe329072e | ||
|
|
c64d1e8361 | ||
|
|
7e17ed7b1b | ||
|
|
3c7fc6b0ee | ||
|
|
8709ad6ff3 | ||
|
|
50d624ef6b | ||
|
|
11e448267d | ||
|
|
aacf741ef5 | ||
|
|
420d80ad6c | ||
|
|
343131c64e | ||
|
|
9b107fbdeb | ||
|
|
60d13e258e | ||
|
|
4f42c2b0d8 | ||
|
|
48ae1111a6 | ||
|
|
76dbe32cfc | ||
|
|
5c0d162a1a | ||
|
|
1df330d3c5 | ||
|
|
120f42cbba | ||
|
|
5f46433f40 | ||
|
|
7b71078c76 | ||
|
|
3e182dbca5 | ||
|
|
ea8f1ee9a4 | ||
|
|
c6db756b68 | ||
|
|
faf09acf65 | ||
|
|
d005d79d54 | ||
|
|
12a1b083c0 | ||
|
|
0ce77987e3 | ||
|
|
62b3044001 | ||
|
|
827bff3cb4 | ||
|
|
f859696b05 | ||
|
|
67bac7c7f9 | ||
|
|
bd4077f04c | ||
|
|
a9048e7270 | ||
|
|
bb8a9f4b1e | ||
|
|
4d99921bde | ||
|
|
32a0e6359c |
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
- users
|
||||
tags:
|
||||
- "v*"
|
||||
- "!*-fdroid"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
1
.github/workflows/web.yml
vendored
1
.github/workflows/web.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
- website/**
|
||||
- images/**
|
||||
- blog/**
|
||||
- docs/**
|
||||
- .github/workflows/web.yml
|
||||
|
||||
jobs:
|
||||
|
||||
45
README.md
45
README.md
@@ -119,19 +119,22 @@ Join our translators to help SimpleX grow!
|
||||
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|
||||
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|
||||
|🇬🇧 en|English | |✓|✓|✓|✓|
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇧🇬 bg|Български |-|[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>-|||
|
||||
|ar|العربية |[jermanuts](https://github.com/jermanuts)|[](https://hosted.weblate.org/projects/simplex-chat/android/ar/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|
||||
|🇧🇬 bg|Български | |[](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|
||||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|
||||
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|
||||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|
||||
|🇫🇮 fi|Suomi | |[](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|
||||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[✓](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|
||||
|🇮🇱 he|עִברִית | |[](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|
||||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|
||||
|🇯🇵 ja|Japanese ||[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|||
|
||||
|🇯🇵 ja|日本語 | |[](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|
||||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|
||||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[](https://hosted.weblate.org/projects/simplex-chat/android/pl/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|
||||
|🇧🇷 pt-BR|Português||[](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|
||||
|🇷🇺 ru|Русский ||[](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|
||||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|
||||
|🇺🇦 uk|Українська| |[](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|
||||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br> |<br><br>[](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
|
||||
|
||||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed – please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
|
||||
@@ -227,24 +230,18 @@ You can use SimpleX with your own servers and still communicate with people usin
|
||||
|
||||
## News and updates
|
||||
|
||||
Recent updates:
|
||||
Recent and important updates:
|
||||
|
||||
[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
[Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
|
||||
|
||||
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
|
||||
|
||||
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
|
||||
|
||||
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
|
||||
|
||||
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
|
||||
|
||||
[Mar 1, 2023. SimpleX File Transfer Protocol – send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
|
||||
|
||||
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
|
||||
|
||||
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
|
||||
|
||||
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
|
||||
|
||||
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
|
||||
@@ -290,18 +287,20 @@ What is already implemented:
|
||||
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
|
||||
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
|
||||
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
|
||||
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
|
||||
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
|
||||
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
|
||||
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
|
||||
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
|
||||
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
|
||||
12. Manual messaging queue rotations to move conversation to another SMP relay.
|
||||
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
|
||||
14. Local files encryption, except videos (to be added later).
|
||||
|
||||
We plan to add:
|
||||
|
||||
1. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`. This is currently in progress.
|
||||
2. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||
1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
|
||||
2. Post-quantum resistant key exchange in double ratchet protocol.
|
||||
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
|
||||
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
|
||||
5. Reproducible builds – this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
|
||||
@@ -365,22 +364,26 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
|
||||
- ✅ Message editing history
|
||||
- ✅ Reduced battery and traffic usage in large groups.
|
||||
- ✅ Message delivery confirmation (with sender opt-out per contact).
|
||||
- 🏗 Desktop client.
|
||||
- ✅ Desktop client.
|
||||
- ✅ Encryption of local files stored in the app.
|
||||
- 🏗 Using mobile profiles from the desktop app.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- Post-quantum resistant key exchange in double ratchet protocol.
|
||||
- Large groups, communities and public channels.
|
||||
- Privacy & security slider - a simple way to set all settings at once.
|
||||
- Improve sending videos (including encryption of locally stored videos).
|
||||
- Improve experience for the new users.
|
||||
- SMP queue redundancy and rotation (manual is supported).
|
||||
- Include optional message into connection request sent via contact address.
|
||||
- Local app files encryption.
|
||||
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
|
||||
- Large groups, communities and public channels.
|
||||
- Feeds/broadcasts.
|
||||
- Ephemeral/disappearing/OTR conversations with the existing contacts.
|
||||
- Privately share your location.
|
||||
- Web widgets for custom interactivity in the chats.
|
||||
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
|
||||
- Supporting the same profile on multiple devices.
|
||||
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
|
||||
- keep all your contacts and groups even if you lose the domain.
|
||||
- the server doesn't have information about your contacts and groups.
|
||||
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
|
||||
- High capacity multi-node SMP relays.
|
||||
|
||||
## Disclaimers
|
||||
|
||||
@@ -1285,6 +1285,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
|
||||
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, _):
|
||||
if active(user) && contact.directOrUsed {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -164,7 +164,7 @@ struct ChatInfoView: View {
|
||||
// synchronizeConnectionButtonForce()
|
||||
// }
|
||||
}
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
|
||||
if let contactLink = contact.contactLink {
|
||||
Section {
|
||||
@@ -181,7 +181,7 @@ struct ChatInfoView: View {
|
||||
}
|
||||
}
|
||||
|
||||
if contact.ready {
|
||||
if contact.ready && contact.active {
|
||||
Section("Servers") {
|
||||
networkStatusRow()
|
||||
.onTapGesture {
|
||||
@@ -192,8 +192,7 @@ struct ChatInfoView: View {
|
||||
alert = .switchAddressAlert
|
||||
}
|
||||
.disabled(
|
||||
!contact.ready
|
||||
|| connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|
||||
|| connStats.ratchetSyncSendProhibited
|
||||
)
|
||||
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
|
||||
|
||||
@@ -60,6 +60,7 @@ struct ChatItemContentView<Content: View>: View {
|
||||
var chatInfo: ChatInfo
|
||||
var chatItem: ChatItem
|
||||
var msgContentView: () -> Content
|
||||
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
|
||||
|
||||
var body: some View {
|
||||
switch chatItem.content {
|
||||
@@ -69,10 +70,16 @@ struct ChatItemContentView<Content: View>: View {
|
||||
case .rcvDeleted: deletedItemView()
|
||||
case let .sndCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvCall(status, duration): callItemView(status, duration)
|
||||
case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem)
|
||||
case let .rcvIntegrityError(msgError):
|
||||
if developerTools {
|
||||
IntegrityErrorItemView(msgError: msgError, chatItem: chatItem)
|
||||
} else {
|
||||
ZStack {}
|
||||
}
|
||||
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
|
||||
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
|
||||
case .rcvDirectEvent: eventItemView()
|
||||
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText)
|
||||
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
|
||||
case .rcvGroupEvent: eventItemView()
|
||||
|
||||
@@ -150,7 +150,7 @@ struct ChatView: View {
|
||||
HStack {
|
||||
if contact.allowsFeature(.calls) {
|
||||
callButton(contact, .audio, imageName: "phone")
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
Menu {
|
||||
if contact.allowsFeature(.calls) {
|
||||
@@ -159,11 +159,11 @@ struct ChatView: View {
|
||||
} label: {
|
||||
Label("Video call", systemImage: "video")
|
||||
}
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
}
|
||||
searchButton()
|
||||
toggleNtfsButton(chat)
|
||||
.disabled(!contact.ready)
|
||||
.disabled(!contact.ready || !contact.active)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis")
|
||||
}
|
||||
@@ -321,6 +321,7 @@ struct ChatView: View {
|
||||
@ViewBuilder private func connectingText() -> some View {
|
||||
if case let .direct(contact) = chat.chatInfo,
|
||||
!contact.ready,
|
||||
contact.active,
|
||||
!contact.nextSendGrpInv {
|
||||
Text("connecting…")
|
||||
.font(.caption)
|
||||
|
||||
@@ -65,7 +65,7 @@ struct ChatListNavLink: View {
|
||||
}
|
||||
Button {
|
||||
AlertManager.shared.showAlert(
|
||||
contact.ready
|
||||
contact.ready || !contact.active
|
||||
? deleteContactAlert(chat.chatInfo)
|
||||
: deletePendingContactAlert(chat, contact)
|
||||
)
|
||||
|
||||
@@ -57,19 +57,26 @@ struct ChatPreviewView: 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) {
|
||||
case .memLeft: groupInactiveIcon()
|
||||
case .memRemoved: groupInactiveIcon()
|
||||
case .memGroupDeleted: groupInactiveIcon()
|
||||
case .memLeft: inactiveIcon()
|
||||
case .memRemoved: inactiveIcon()
|
||||
case .memGroupDeleted: inactiveIcon()
|
||||
default: EmptyView()
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func groupInactiveIcon() -> some View {
|
||||
@ViewBuilder private func inactiveIcon() -> some View {
|
||||
Image(systemName: "multiply.circle.fill")
|
||||
.foregroundColor(.secondary.opacity(0.65))
|
||||
.background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
|
||||
@@ -80,7 +87,6 @@ struct ChatPreviewView: View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
previewTitle(contact.verified == true ? verifiedIcon + t : t)
|
||||
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
|
||||
case let .group(groupInfo):
|
||||
let v = previewTitle(t)
|
||||
switch (groupInfo.membership.memberStatus) {
|
||||
@@ -183,7 +189,7 @@ struct ChatPreviewView: View {
|
||||
if !contact.ready {
|
||||
if contact.nextSendGrpInv {
|
||||
chatPreviewInfoText("send direct message")
|
||||
} else {
|
||||
} else if contact.active {
|
||||
chatPreviewInfoText("connecting…")
|
||||
}
|
||||
}
|
||||
@@ -228,16 +234,20 @@ struct ChatPreviewView: View {
|
||||
@ViewBuilder private func chatStatusImage() -> some View {
|
||||
switch chat.chatInfo {
|
||||
case let .direct(contact):
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
if contact.active {
|
||||
switch (chatModel.contactNetworkStatus(contact)) {
|
||||
case .connected: incognitoIcon(chat.chatInfo.incognito)
|
||||
case .error:
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 17, height: 17)
|
||||
.foregroundColor(.secondary)
|
||||
default:
|
||||
ProgressView()
|
||||
}
|
||||
} else {
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
default:
|
||||
incognitoIcon(chat.chatInfo.incognito)
|
||||
|
||||
@@ -48,11 +48,6 @@
|
||||
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; };
|
||||
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; };
|
||||
5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; };
|
||||
5C5625102ABDFA8900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250B2ABDFA8900A21210 /* libffi.a */; };
|
||||
5C5625112ABDFA8900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250C2ABDFA8900A21210 /* libgmp.a */; };
|
||||
5C5625122ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */; };
|
||||
5C5625132ABDFA8900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250E2ABDFA8900A21210 /* libgmpxx.a */; };
|
||||
5C5625142ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */; };
|
||||
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
|
||||
5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; };
|
||||
5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; };
|
||||
@@ -119,6 +114,11 @@
|
||||
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; };
|
||||
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */; };
|
||||
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */; };
|
||||
5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */; };
|
||||
5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC739892AC9D168009470A9 /* libgmp.a */; };
|
||||
5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398A2AC9D168009470A9 /* libffi.a */; };
|
||||
5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398B2AC9D168009470A9 /* libgmpxx.a */; };
|
||||
5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */; };
|
||||
5CC868F329EB540C0017BBFD /* CIRcvDecryptionError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */; };
|
||||
5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; };
|
||||
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
|
||||
@@ -293,11 +293,6 @@
|
||||
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; };
|
||||
5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = "<group>"; };
|
||||
5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = "<group>"; };
|
||||
5C56250B2ABDFA8900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5C56250C2ABDFA8900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a"; sourceTree = "<group>"; };
|
||||
5C56250E2ABDFA8900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
|
||||
5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = "<group>"; };
|
||||
5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
@@ -400,6 +395,11 @@
|
||||
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; };
|
||||
5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a"; sourceTree = "<group>"; };
|
||||
5CC739892AC9D168009470A9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
|
||||
5CC7398A2AC9D168009470A9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
|
||||
5CC7398B2AC9D168009470A9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
|
||||
5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a"; sourceTree = "<group>"; };
|
||||
5CC868F229EB540C0017BBFD /* CIRcvDecryptionError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIRcvDecryptionError.swift; sourceTree = "<group>"; };
|
||||
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
|
||||
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
|
||||
@@ -507,12 +507,12 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
5C5625132ABDFA8900A21210 /* libgmpxx.a in Frameworks */,
|
||||
5C5625122ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a in Frameworks */,
|
||||
5CC739902AC9D168009470A9 /* libgmpxx.a in Frameworks */,
|
||||
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
|
||||
5C5625142ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a in Frameworks */,
|
||||
5C5625102ABDFA8900A21210 /* libffi.a in Frameworks */,
|
||||
5C5625112ABDFA8900A21210 /* libgmp.a in Frameworks */,
|
||||
5CC7398D2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a in Frameworks */,
|
||||
5CC7398E2AC9D168009470A9 /* libgmp.a in Frameworks */,
|
||||
5CC739912AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a in Frameworks */,
|
||||
5CC7398F2AC9D168009470A9 /* libffi.a in Frameworks */,
|
||||
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -574,11 +574,11 @@
|
||||
5C764E5C279C70B7000C6508 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
5C56250B2ABDFA8900A21210 /* libffi.a */,
|
||||
5C56250C2ABDFA8900A21210 /* libgmp.a */,
|
||||
5C56250E2ABDFA8900A21210 /* libgmpxx.a */,
|
||||
5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */,
|
||||
5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */,
|
||||
5CC7398A2AC9D168009470A9 /* libffi.a */,
|
||||
5CC739892AC9D168009470A9 /* libgmp.a */,
|
||||
5CC7398B2AC9D168009470A9 /* libgmpxx.a */,
|
||||
5CC739882AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp-ghc8.10.7.a */,
|
||||
5CC7398C2AC9D168009470A9 /* libHSsimplex-chat-5.4.0.0-EcKpK3v0NXRK7pgDye4kqp.a */,
|
||||
);
|
||||
path = Libraries;
|
||||
sourceTree = "<group>";
|
||||
@@ -1486,7 +1486,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1507,7 +1507,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.3;
|
||||
MARKETING_VERSION = 5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1528,7 +1528,7 @@
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
@@ -1549,7 +1549,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.3;
|
||||
MARKETING_VERSION = 5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
|
||||
PRODUCT_NAME = SimpleX;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1608,7 +1608,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1621,7 +1621,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.3;
|
||||
MARKETING_VERSION = 5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1640,7 +1640,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
ENABLE_BITCODE = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -1653,7 +1653,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 5.3;
|
||||
MARKETING_VERSION = 5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
@@ -1672,7 +1672,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1696,7 +1696,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.3;
|
||||
MARKETING_VERSION = 5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1718,7 +1718,7 @@
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 173;
|
||||
CURRENT_PROJECT_VERSION = 176;
|
||||
DEFINES_MODULE = YES;
|
||||
DEVELOPMENT_TEAM = 5NN7GUYB6T;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
@@ -1742,7 +1742,7 @@
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)/Libraries/sim",
|
||||
);
|
||||
MARKETING_VERSION = 5.3;
|
||||
MARKETING_VERSION = 5.4;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -462,6 +462,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case contactAlreadyExists(user: UserRef, contact: Contact)
|
||||
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
|
||||
case contactDeleted(user: UserRef, contact: Contact)
|
||||
case contactDeletedByContact(user: UserRef, contact: Contact)
|
||||
case chatCleared(user: UserRef, chatInfo: ChatInfo)
|
||||
case userProfileNoChange(user: User)
|
||||
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
|
||||
@@ -599,6 +600,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .contactAlreadyExists: return "contactAlreadyExists"
|
||||
case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted"
|
||||
case .contactDeleted: return "contactDeleted"
|
||||
case .contactDeletedByContact: return "contactDeletedByContact"
|
||||
case .chatCleared: return "chatCleared"
|
||||
case .userProfileNoChange: return "userProfileNoChange"
|
||||
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 .contactRequestAlreadyAccepted(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 .userProfileNoChange: return noDetails
|
||||
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
|
||||
@@ -1420,6 +1423,7 @@ public enum ChatErrorType: Decodable {
|
||||
case invalidConnReq
|
||||
case invalidChatMessage(connection: Connection, message: String)
|
||||
case contactNotReady(contact: Contact)
|
||||
case contactNotActive(contact: Contact)
|
||||
case contactDisabled(contact: Contact)
|
||||
case connectionDisabled(connection: Connection)
|
||||
case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole)
|
||||
|
||||
@@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var activeConn: Connection
|
||||
public var viaGroup: Int64?
|
||||
public var contactUsed: Bool
|
||||
public var contactStatus: ContactStatus
|
||||
public var chatSettings: ChatSettings
|
||||
public var userPreferences: Preferences
|
||||
public var mergedPreferences: ContactUserPreferences
|
||||
@@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
public var id: ChatId { get { "@\(contactId)" } }
|
||||
public var apiId: Int64 { get { contactId } }
|
||||
public var ready: Bool { get { activeConn.connStatus == .ready } }
|
||||
public var active: Bool { get { contactStatus == .active } }
|
||||
public var sendMsgEnabled: Bool { get {
|
||||
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|
||||
(ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|
||||
|| nextSendGrpInv
|
||||
} }
|
||||
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
|
||||
@@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
|
||||
profile: LocalProfile.sampleData,
|
||||
activeConn: Connection.sampleData,
|
||||
contactUsed: true,
|
||||
contactStatus: .active,
|
||||
chatSettings: ChatSettings.defaults,
|
||||
userPreferences: Preferences.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 {
|
||||
var contactId: Int64
|
||||
public var agentConnId: String
|
||||
@@ -2091,6 +2099,7 @@ public struct ChatItem: Identifiable, Decodable {
|
||||
case .rcvDecryptionError: return showNtfDir
|
||||
case .rcvGroupInvitation: return showNtfDir
|
||||
case .sndGroupInvitation: return showNtfDir
|
||||
case .rcvDirectEvent: return false
|
||||
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
|
||||
switch rcvGroupEvent {
|
||||
case .groupUpdated: return false
|
||||
@@ -2513,6 +2522,7 @@ public enum CIContent: Decodable, ItemContent {
|
||||
case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32)
|
||||
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
|
||||
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
|
||||
case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent)
|
||||
case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent)
|
||||
case sndGroupEvent(sndGroupEvent: SndGroupEvent)
|
||||
case rcvConnEvent(rcvConnEvent: RcvConnEvent)
|
||||
@@ -2542,6 +2552,7 @@ public enum CIContent: Decodable, ItemContent {
|
||||
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
|
||||
case let .rcvGroupInvitation(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 .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
|
||||
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
|
||||
@@ -3195,6 +3206,16 @@ public enum CIGroupInvitationStatus: String, Decodable {
|
||||
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 {
|
||||
case memberAdded(groupMemberId: Int64, profile: Profile)
|
||||
case memberConnected
|
||||
|
||||
@@ -97,7 +97,7 @@ kotlin {
|
||||
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
|
||||
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
|
||||
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
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.helpers.*
|
||||
|
||||
@Composable
|
||||
actual fun ChatListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
click: () -> Unit,
|
||||
dropdownMenuItems: (@Composable () -> Unit)?,
|
||||
showMenu: MutableState<Boolean>,
|
||||
stopped: Boolean,
|
||||
selectedChat: State<Boolean>
|
||||
) {
|
||||
var modifier = Modifier.fillMaxWidth()
|
||||
if (!stopped) modifier = modifier
|
||||
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
.onRightClick { showMenu.value = true }
|
||||
Box(modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
chatLinkPreview()
|
||||
}
|
||||
if (dropdownMenuItems != null) {
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
@@ -60,7 +60,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j
|
||||
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
|
||||
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
|
||||
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm);
|
||||
(*env)->ReleaseStringUTFChars(env, confirm, _confirm);
|
||||
|
||||
// Creating array of Object's (boxed values can be passed, eg. Long instead of long)
|
||||
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
|
||||
|
||||
@@ -31,9 +31,9 @@ else()
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
endif()
|
||||
|
||||
if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64")
|
||||
if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "AMD64")
|
||||
set(OS_LIB_ARCH "x86_64")
|
||||
elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64")
|
||||
elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "ARM64")
|
||||
set(OS_LIB_ARCH "aarch64")
|
||||
else()
|
||||
set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
|
||||
@@ -55,8 +55,13 @@ add_library( # Sets the name of the library.
|
||||
|
||||
add_library( simplex SHARED IMPORTED )
|
||||
# Lib has different name because of version, find it
|
||||
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSsimplex-chat-*.${OS_LIB_EXT})
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
|
||||
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT})
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB})
|
||||
else()
|
||||
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
|
||||
endif()
|
||||
|
||||
|
||||
# Specifies libraries CMake should link to your target library. You
|
||||
@@ -73,6 +78,11 @@ else()
|
||||
target_link_libraries(app-lib rts simplex)
|
||||
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
|
||||
|
||||
8
apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh
Executable file
8
apps/multiplatform/common/src/commonMain/cpp/desktop/patch-libapp-mac.sh
Executable 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
|
||||
@@ -224,6 +224,7 @@ object ChatModel {
|
||||
}
|
||||
// add to current chat
|
||||
if (chatId.value == cInfo.id) {
|
||||
Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
withContext(Dispatchers.Main) {
|
||||
// Prevent situation when chat item already in the list received from backend
|
||||
if (chatItems.none { it.id == cItem.id }) {
|
||||
@@ -231,6 +232,7 @@ object ChatModel {
|
||||
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,13 +261,16 @@ object ChatModel {
|
||||
}
|
||||
// update current chat
|
||||
return if (chatId.value == cInfo.id) {
|
||||
Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
withContext(Dispatchers.Main) {
|
||||
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
|
||||
if (itemIndex >= 0) {
|
||||
chatItems[itemIndex] = cItem
|
||||
Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
false
|
||||
} else {
|
||||
chatItems.add(cItem)
|
||||
Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
|
||||
true
|
||||
}
|
||||
}
|
||||
@@ -374,6 +379,7 @@ object ChatModel {
|
||||
var markedRead = 0
|
||||
if (chatId.value == cInfo.id) {
|
||||
var i = 0
|
||||
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}")
|
||||
while (i < chatItems.count()) {
|
||||
val item = chatItems[i]
|
||||
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
|
||||
@@ -388,6 +394,7 @@ object ChatModel {
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}")
|
||||
}
|
||||
return markedRead
|
||||
}
|
||||
@@ -797,6 +804,7 @@ data class Contact(
|
||||
val activeConn: Connection,
|
||||
val viaGroup: Long? = null,
|
||||
val contactUsed: Boolean,
|
||||
val contactStatus: ContactStatus,
|
||||
val chatSettings: ChatSettings,
|
||||
val userPreferences: ChatPreferences,
|
||||
val mergedPreferences: ContactUserPreferences,
|
||||
@@ -809,8 +817,9 @@ data class Contact(
|
||||
override val id get() = "@$contactId"
|
||||
override val apiId get() = contactId
|
||||
override val ready get() = activeConn.connStatus == ConnStatus.Ready
|
||||
val active get() = contactStatus == ContactStatus.Active
|
||||
override val sendMsgEnabled get() =
|
||||
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|
||||
(ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|
||||
|| nextSendGrpInv
|
||||
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
|
||||
override val ntfsEnabled get() = chatSettings.enableNtfs
|
||||
@@ -859,6 +868,7 @@ data class Contact(
|
||||
profile = LocalProfile.sampleData,
|
||||
activeConn = Connection.sampleData,
|
||||
contactUsed = true,
|
||||
contactStatus = ContactStatus.Active,
|
||||
chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false),
|
||||
userPreferences = ChatPreferences.sampleData,
|
||||
mergedPreferences = ContactUserPreferences.sampleData,
|
||||
@@ -869,6 +879,12 @@ data class Contact(
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ContactStatus {
|
||||
@SerialName("active") Active,
|
||||
@SerialName("deleted") Deleted;
|
||||
}
|
||||
|
||||
@Serializable
|
||||
class ContactRef(
|
||||
val contactId: Long,
|
||||
@@ -1471,6 +1487,7 @@ data class ChatItem (
|
||||
is CIContent.RcvDecryptionError -> showNtfDir
|
||||
is CIContent.RcvGroupInvitation -> showNtfDir
|
||||
is CIContent.SndGroupInvitation -> showNtfDir
|
||||
is CIContent.RcvDirectEventContent -> false
|
||||
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
|
||||
is RcvGroupEvent.MemberAdded -> false
|
||||
is RcvGroupEvent.MemberConnected -> false
|
||||
@@ -1854,6 +1871,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("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("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("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 }
|
||||
@@ -1881,6 +1899,7 @@ sealed class CIContent: ItemContent {
|
||||
is RcvDecryptionError -> msgDecryptError.text
|
||||
is RcvGroupInvitation -> groupInvitation.text
|
||||
is SndGroupInvitation -> groupInvitation.text
|
||||
is RcvDirectEventContent -> rcvDirectEvent.text
|
||||
is RcvGroupEventContent -> rcvGroupEvent.text
|
||||
is SndGroupEventContent -> sndGroupEvent.text
|
||||
is RcvConnEventContent -> rcvConnEvent.text
|
||||
@@ -2487,6 +2506,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
|
||||
sealed class RcvGroupEvent() {
|
||||
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
|
||||
|
||||
@@ -106,6 +106,7 @@ class AppPreferences {
|
||||
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
|
||||
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
|
||||
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
|
||||
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
|
||||
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
|
||||
val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
|
||||
private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name)
|
||||
@@ -265,6 +266,7 @@ class AppPreferences {
|
||||
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
|
||||
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
|
||||
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
|
||||
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
|
||||
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
|
||||
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
|
||||
private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"
|
||||
@@ -1366,6 +1368,11 @@ object ChatController {
|
||||
chatModel.removeChat(r.connection.id)
|
||||
}
|
||||
}
|
||||
is CR.ContactDeletedByContact -> {
|
||||
if (active(r.user) && r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
}
|
||||
}
|
||||
is CR.ContactConnected -> {
|
||||
if (active(r.user) && r.contact.directOrUsed) {
|
||||
chatModel.updateContact(r.contact)
|
||||
@@ -3295,6 +3302,7 @@ sealed class 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("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("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()
|
||||
@@ -3426,6 +3434,7 @@ sealed class CR {
|
||||
is ContactAlreadyExists -> "contactAlreadyExists"
|
||||
is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted"
|
||||
is ContactDeleted -> "contactDeleted"
|
||||
is ContactDeletedByContact -> "contactDeletedByContact"
|
||||
is ChatCleared -> "chatCleared"
|
||||
is UserProfileNoChange -> "userProfileNoChange"
|
||||
is UserProfileUpdated -> "userProfileUpdated"
|
||||
@@ -3554,6 +3563,7 @@ sealed class CR {
|
||||
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
|
||||
is ContactRequestAlreadyAccepted -> 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 UserProfileNoChange -> withUser(user, noDetails())
|
||||
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
|
||||
@@ -3822,6 +3832,7 @@ sealed class ChatErrorType {
|
||||
is InvalidConnReq -> "invalidConnReq"
|
||||
is InvalidChatMessage -> "invalidChatMessage"
|
||||
is ContactNotReady -> "contactNotReady"
|
||||
is ContactNotActive -> "contactNotActive"
|
||||
is ContactDisabled -> "contactDisabled"
|
||||
is ConnectionDisabled -> "connectionDisabled"
|
||||
is GroupUserRole -> "groupUserRole"
|
||||
@@ -3897,6 +3908,7 @@ sealed class ChatErrorType {
|
||||
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
|
||||
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): 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("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType()
|
||||
@Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType()
|
||||
|
||||
@@ -123,7 +123,18 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
}
|
||||
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } }
|
||||
val reversedTerminalItems by remember {
|
||||
derivedStateOf {
|
||||
// Such logic prevents concurrent modification
|
||||
val res = ArrayList<TerminalItem>()
|
||||
var i = 0
|
||||
while (i < terminalItems.size) {
|
||||
res.add(terminalItems[i])
|
||||
i++
|
||||
}
|
||||
res.asReversed()
|
||||
}
|
||||
}
|
||||
val clipboard = LocalClipboardManager.current
|
||||
LazyColumn(state = listState, reverseLayout = true) {
|
||||
items(reversedTerminalItems) { item ->
|
||||
|
||||
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.text.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -291,7 +290,7 @@ fun ChatInfoLayout(
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (contact.ready) {
|
||||
if (contact.ready && contact.active) {
|
||||
SectionView {
|
||||
if (connectionCode != null) {
|
||||
VerifyCodeButton(contact.verified, verifyClicked)
|
||||
@@ -318,7 +317,7 @@ fun ChatInfoLayout(
|
||||
SectionDividerSpaced()
|
||||
}
|
||||
|
||||
if (contact.ready) {
|
||||
if (contact.ready && contact.active) {
|
||||
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
|
||||
SectionItemView({
|
||||
AlertManager.shared.showAlertMsg(
|
||||
|
||||
@@ -66,11 +66,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
launch {
|
||||
snapshotFlow { chatModel.chatId.value }
|
||||
.distinctUntilChanged()
|
||||
.onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") }
|
||||
.filter { it != null && activeChat.value?.id != it }
|
||||
.collect { chatId ->
|
||||
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
|
||||
// Also for situation when chatId changes after clicking in notification, etc
|
||||
activeChat.value = chatModel.getChat(chatId!!)
|
||||
Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}")
|
||||
markUnreadChatAsRead(activeChat, chatModel)
|
||||
}
|
||||
}
|
||||
@@ -89,9 +91,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") }
|
||||
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
|
||||
.filter { it != null && it?.chatInfo != activeChat.value?.chatInfo }
|
||||
.collect { activeChat.value = it }
|
||||
.collect {
|
||||
activeChat.value = it
|
||||
Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}")
|
||||
}
|
||||
}
|
||||
}
|
||||
val view = LocalMultiplatformView()
|
||||
@@ -118,7 +124,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
Modifier.fillMaxWidth(),
|
||||
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(
|
||||
generalGetString(MR.strings.contact_connection_pending),
|
||||
Modifier.padding(top = 4.dp),
|
||||
@@ -213,7 +224,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
val firstId = chatModel.chatItems.firstOrNull()?.id
|
||||
if (c != null && firstId != null) {
|
||||
withApi {
|
||||
Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}")
|
||||
apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value)
|
||||
Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}")
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -394,6 +407,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
}
|
||||
},
|
||||
onComposed,
|
||||
developerTools = chatModel.controller.appPrefs.developerTools.get(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -435,6 +449,7 @@ fun ChatLayout(
|
||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
|
||||
@@ -500,7 +515,7 @@ fun ChatLayout(
|
||||
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage,
|
||||
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat,
|
||||
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
|
||||
setReaction, showItemDetails, markRead, setFloatingButton, onComposed,
|
||||
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -548,15 +563,15 @@ fun ChatInfoToolbar(
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
},
|
||||
enabled = chat.chatInfo.contact.ready) {
|
||||
enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_call_500),
|
||||
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 {
|
||||
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
|
||||
showMenu.value = false
|
||||
@@ -574,7 +589,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) }
|
||||
menuItems.add {
|
||||
ItemAction(
|
||||
@@ -695,6 +710,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
|
||||
setFloatingButton: (@Composable () -> Unit) -> Unit,
|
||||
onComposed: suspend (chatId: String) -> Unit,
|
||||
developerTools: Boolean,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -831,7 +847,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
) {
|
||||
MemberImage(member)
|
||||
}
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -840,7 +856,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
|
||||
.then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -850,7 +866,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
|
||||
.then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
|
||||
}
|
||||
}
|
||||
} else { // direct message
|
||||
@@ -861,7 +877,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
|
||||
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
|
||||
).then(swipeableModifier)
|
||||
) {
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails)
|
||||
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1300,6 +1316,7 @@ fun PreviewChatLayout() {
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
onComposed = {},
|
||||
developerTools = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1368,6 +1385,7 @@ fun PreviewGroupChatLayout() {
|
||||
changeNtfsState = { _, _ -> },
|
||||
onSearchValueChanged = {},
|
||||
onComposed = {},
|
||||
developerTools = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ private fun VideoView(uri: URI, file: CIFile, defaultPreview: ImageBitmap, defau
|
||||
)
|
||||
if (showPreview.value) {
|
||||
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*/)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ fun ChatItemView(
|
||||
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
|
||||
showItemDetails: (ChatInfo, ChatItem) -> Unit,
|
||||
getConnectedMemberNames: (() -> List<String>)? = null,
|
||||
developerTools: Boolean,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val sent = cItem.chatDir.sent
|
||||
@@ -343,10 +344,15 @@ fun ChatItemView(
|
||||
is CIContent.RcvDeleted -> DeletedItem()
|
||||
is CIContent.SndCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvCall -> CallItem(c.status, c.duration)
|
||||
is CIContent.RcvIntegrityError -> IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL)
|
||||
is CIContent.RcvIntegrityError -> if (developerTools) {
|
||||
IntegrityErrorItemView(c.msgError, cItem, cInfo.timedMessagesTTL)
|
||||
} else {
|
||||
Box(Modifier.size(0.dp)) {}
|
||||
}
|
||||
is CIContent.RcvDecryptionError -> CIRcvDecryptionError(c.msgDecryptError, c.msgCount, cInfo, cItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember)
|
||||
is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup, chatIncognito = cInfo.incognito)
|
||||
is CIContent.RcvDirectEventContent -> EventItemView()
|
||||
is CIContent.RcvGroupEventContent -> when (c.rcvGroupEvent) {
|
||||
is RcvGroupEvent.MemberConnected -> CIEventView(membersConnectedItemText())
|
||||
is RcvGroupEvent.MemberCreatedContact -> CIMemberCreatedContactView(cItem, openDirectChat)
|
||||
@@ -583,6 +589,7 @@ fun PreviewChatItemView() {
|
||||
findModelMember = { null },
|
||||
setReaction = { _, _, _, _ -> },
|
||||
showItemDetails = { _, _ -> },
|
||||
developerTools = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -613,6 +620,7 @@ fun PreviewChatItemViewDeletedContent() {
|
||||
findModelMember = { null },
|
||||
setReaction = { _, _, _, _ -> },
|
||||
showItemDetails = { _, _ -> },
|
||||
developerTools = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,12 +158,11 @@ private fun VideoView(modifier: Modifier, uri: URI, defaultPreview: ImageBitmap,
|
||||
player.stop()
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
player.enableSound(true)
|
||||
snapshotFlow { isCurrentPage.value }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
// Do not autoplay on desktop because it needs workaround
|
||||
if (it && appPlatform.isAndroid) play() else if (!it) stop()
|
||||
if (it) play() else stop()
|
||||
player.enableSound(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import SectionItemView
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -14,6 +13,10 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -44,6 +47,7 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
showMenu.value = false
|
||||
delay(500L)
|
||||
}
|
||||
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } }
|
||||
val showChatPreviews = chatModel.showChatPreviews.value
|
||||
when (chat.chatInfo) {
|
||||
is ChatInfo.Direct -> {
|
||||
@@ -53,7 +57,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
click = { directChatAction(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
stopped
|
||||
stopped,
|
||||
selectedChat
|
||||
)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
@@ -62,7 +67,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
|
||||
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
|
||||
showMenu,
|
||||
stopped
|
||||
stopped,
|
||||
selectedChat
|
||||
)
|
||||
is ChatInfo.ContactRequest ->
|
||||
ChatListNavLinkLayout(
|
||||
@@ -70,7 +76,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
click = { contactRequestAlertDialog(chat.chatInfo, chatModel) },
|
||||
dropdownMenuItems = { ContactRequestMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||
showMenu,
|
||||
stopped
|
||||
stopped,
|
||||
selectedChat
|
||||
)
|
||||
is ChatInfo.ContactConnection ->
|
||||
ChatListNavLinkLayout(
|
||||
@@ -84,7 +91,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
},
|
||||
dropdownMenuItems = { ContactConnectionMenuItems(chat.chatInfo, chatModel, showMenu) },
|
||||
showMenu,
|
||||
stopped
|
||||
stopped,
|
||||
selectedChat
|
||||
)
|
||||
is ChatInfo.InvalidJSON ->
|
||||
ChatListNavLinkLayout(
|
||||
@@ -97,7 +105,8 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
|
||||
},
|
||||
dropdownMenuItems = null,
|
||||
showMenu,
|
||||
stopped
|
||||
stopped,
|
||||
selectedChat
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -122,9 +131,11 @@ suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (chat != null) {
|
||||
openChat(chat, chatModel)
|
||||
Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -628,32 +639,14 @@ fun updateChatSettings(chat: Chat, chatSettings: ChatSettings, chatModel: ChatMo
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatListNavLinkLayout(
|
||||
expect fun ChatListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
click: () -> Unit,
|
||||
dropdownMenuItems: (@Composable () -> Unit)?,
|
||||
showMenu: MutableState<Boolean>,
|
||||
stopped: Boolean
|
||||
) {
|
||||
var modifier = Modifier.fillMaxWidth()
|
||||
if (!stopped) modifier = modifier
|
||||
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
.onRightClick { showMenu.value = true }
|
||||
Box(modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
chatLinkPreview()
|
||||
}
|
||||
if (dropdownMenuItems != null) {
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||
}
|
||||
}
|
||||
Divider(Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
stopped: Boolean,
|
||||
selectedChat: State<Boolean>
|
||||
)
|
||||
|
||||
@Preview/*(
|
||||
uiMode = Configuration.UI_MODE_NIGHT_YES,
|
||||
@@ -690,7 +683,8 @@ fun PreviewChatListNavLinkDirect() {
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
showMenu = remember { mutableStateOf(false) },
|
||||
stopped = false
|
||||
stopped = false,
|
||||
selectedChat = remember { mutableStateOf(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -730,7 +724,8 @@ fun PreviewChatListNavLinkGroup() {
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
showMenu = remember { mutableStateOf(false) },
|
||||
stopped = false
|
||||
stopped = false,
|
||||
selectedChat = remember { mutableStateOf(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -750,7 +745,8 @@ fun PreviewChatListNavLinkContactRequest() {
|
||||
click = {},
|
||||
dropdownMenuItems = null,
|
||||
showMenu = remember { mutableStateOf(false) },
|
||||
stopped = false
|
||||
stopped = false,
|
||||
selectedChat = remember { mutableStateOf(false) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ fun ChatPreviewView(
|
||||
val cInfo = chat.chatInfo
|
||||
|
||||
@Composable
|
||||
fun groupInactiveIcon() {
|
||||
fun inactiveIcon() {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_cancel_filled),
|
||||
stringResource(MR.strings.icon_descr_group_inactive),
|
||||
@@ -53,13 +53,19 @@ fun ChatPreviewView(
|
||||
|
||||
@Composable
|
||||
fun chatPreviewImageOverlayIcon() {
|
||||
if (cInfo is ChatInfo.Group) {
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemLeft -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemRemoved -> groupInactiveIcon()
|
||||
GroupMemberStatus.MemGroupDeleted -> groupInactiveIcon()
|
||||
else -> {}
|
||||
when (cInfo) {
|
||||
is ChatInfo.Direct ->
|
||||
if (!cInfo.contact.active) {
|
||||
inactiveIcon()
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
GroupMemberStatus.MemLeft -> inactiveIcon()
|
||||
GroupMemberStatus.MemRemoved -> inactiveIcon()
|
||||
GroupMemberStatus.MemGroupDeleted -> inactiveIcon()
|
||||
else -> {}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +131,7 @@ fun ChatPreviewView(
|
||||
if (cInfo.contact.verified) {
|
||||
VerifiedIcon()
|
||||
}
|
||||
chatPreviewTitleText(if (cInfo.ready) Color.Unspecified else MaterialTheme.colors.secondary)
|
||||
chatPreviewTitleText()
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
when (cInfo.groupInfo.membership.memberStatus) {
|
||||
@@ -174,7 +180,7 @@ fun ChatPreviewView(
|
||||
is ChatInfo.Direct ->
|
||||
if (cInfo.contact.nextSendGrpInv) {
|
||||
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)
|
||||
}
|
||||
is ChatInfo.Group ->
|
||||
@@ -191,28 +197,32 @@ fun ChatPreviewView(
|
||||
@Composable
|
||||
fun chatStatusImage() {
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
val descr = contactNetworkStatus?.statusString
|
||||
when (contactNetworkStatus) {
|
||||
is NetworkStatus.Connected ->
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
if (cInfo.contact.active) {
|
||||
val descr = contactNetworkStatus?.statusString
|
||||
when (contactNetworkStatus) {
|
||||
is NetworkStatus.Connected ->
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
|
||||
is NetworkStatus.Error ->
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
contentDescription = descr,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(19.dp)
|
||||
)
|
||||
is NetworkStatus.Error ->
|
||||
Icon(
|
||||
painterResource(MR.images.ic_error),
|
||||
contentDescription = descr,
|
||||
tint = MaterialTheme.colors.secondary,
|
||||
modifier = Modifier
|
||||
.size(19.dp)
|
||||
)
|
||||
|
||||
else ->
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(15.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 1.5.dp
|
||||
)
|
||||
else ->
|
||||
CircularProgressIndicator(
|
||||
Modifier
|
||||
.padding(horizontal = 2.dp)
|
||||
.size(15.dp),
|
||||
color = MaterialTheme.colors.secondary,
|
||||
strokeWidth = 1.5.dp
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
}
|
||||
} else {
|
||||
IncognitoIcon(chat.chatInfo.incognito)
|
||||
|
||||
@@ -34,6 +34,17 @@ fun DeveloperView(
|
||||
ChatConsoleItem { withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential), showCustomModal { it, close -> TerminalView(it, close) })}
|
||||
SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
|
||||
SettingsPreferenceItem(painterResource(MR.images.ic_code), stringResource(MR.strings.show_developer_options), developerTools)
|
||||
if (appPlatform.isDesktop && devTools.value) {
|
||||
TerminalAlwaysVisibleItem(m.controller.appPrefs.terminalAlwaysVisible) { checked ->
|
||||
if (checked) {
|
||||
withAuth(generalGetString(MR.strings.auth_open_chat_console), generalGetString(MR.strings.auth_log_in_using_credential)) {
|
||||
m.controller.appPrefs.terminalAlwaysVisible.set(true)
|
||||
}
|
||||
} else {
|
||||
m.controller.appPrefs.terminalAlwaysVisible.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
SectionTextFooter(
|
||||
generalGetString(if (devTools.value) MR.strings.show_dev_options else MR.strings.hide_dev_options) + " " +
|
||||
|
||||
@@ -322,6 +322,15 @@ fun ChatLockItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun TerminalAlwaysVisibleItem(pref: SharedPreference<Boolean>, onChange: (Boolean) -> Unit) {
|
||||
SettingsActionItemWithContent(painterResource(MR.images.ic_engineering), stringResource(MR.strings.terminal_always_visible), extraPadding = false) {
|
||||
DefaultSwitch(
|
||||
checked = remember { pref.state }.value,
|
||||
onCheckedChange = onChange,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable fun InstallTerminalAppItem(uriHandler: UriHandler) {
|
||||
SectionItemView({ uriHandler.openUriCatching("https://github.com/simplex-chat/simplex-chat") }) {
|
||||
Icon(
|
||||
|
||||
@@ -1056,6 +1056,7 @@
|
||||
<string name="database_downgrade">Database downgrade</string>
|
||||
<string name="incompatible_database_version">Incompatible database version</string>
|
||||
<string name="confirm_database_upgrades">Confirm database upgrades</string>
|
||||
<string name="terminal_always_visible">Show console in new window</string>
|
||||
<string name="invalid_migration_confirmation">Invalid migration confirmation</string>
|
||||
<string name="upgrade_and_open_chat">Upgrade and open chat</string>
|
||||
<string name="downgrade_and_open_chat">Downgrade and open chat</string>
|
||||
@@ -1105,6 +1106,9 @@
|
||||
<string name="you_rejected_group_invitation">You rejected group invitation</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 -->
|
||||
<string name="rcv_group_event_member_added">invited %1$s</string>
|
||||
<string name="rcv_group_event_member_connected">connected</string>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M46-124v-90q0-33.5 15.516-55.596Q77.03-291.692 106-305.5q54-26 114.624-42.75Q281.249-365 359-365t138.376 16.75Q558-331.5 612-305.5q28.969 13.808 44.484 35.904Q672-247.5 672-214v90H46Zm57.5-57.5h511v-32.744q0-15.256-8.75-25.006t-20.65-14.792q-42.6-19.153-95.35-36.305Q437-307.5 359-307.5t-130.75 17.153q-52.75 17.152-95.35 36.305Q121-249 112.25-239.25q-8.75 9.75-8.75 25.006v32.744Zm255.456-243q-64.456 0-105.86-41.984Q211.692-508.469 211.692-572H201.5q-8 0-14-6t-6-14q0-8 6-14t14-6h10.185q0-39.154 19.158-70.077Q250-713 282.143-733v38.459q0 6.244 4.098 10.392Q290.339-680 296.5-680q7.225 0 10.862-4.138Q311-688.275 311-694.364v-51.631q7.845-1.98 22.172-3.492Q347.5-751 360-751t26.828 1.511q14.327 1.511 22.172 3.487v51.573q0 5.929 3.638 10.179Q416.275-680 423.5-680q6.161 0 10.259-4.149 4.098-4.148 4.098-10.2V-733q32.143 20 50.3 50.923 18.158 30.923 18.158 70.077H516.5q8 0 14 6t6 14q0 8-6 14t-14 6h-10.192q0 63.531-41.448 105.516Q423.411-424.5 358.956-424.5ZM359-482q42 0 66-25t24-65H269q0 40 24 65t66 25Zm300 119.5-1.885-29q-7.115-4-14.615-9t-13.5-10l-26 14-21.5-31 26-19q-2-4-2-7.5v-15q0-3.5 2-7.5l-26-19 21.5-31 26 14q7-5.5 13.97-10.312 6.969-4.813 13.939-8.688l1.906-29h39.37l1.906 29q6.97 3.875 13.939 8.688Q721-518 728-512.5l26-14 21.5 31-26 19q2 4 2 7.5v15q0 3.5-2 7.5l26 19-21.5 31-26-14q-6 5-13.5 10t-14.5 9l-1.769 29H659Zm19.5-61q16 0 27-11t11-27q0-16-11-27t-27-11q-16 0-27 11t-11 27q0 16 11 27t27 11ZM766.349-579l-8.164-33.933Q748-617 737.179-624q-10.822-7-17.554-15L676-624l-19-33 34-27.5q-2-5-3.5-11.25T686-708q0-6 1.5-12.25t3.5-11.25L657-759l19-33 43.562 15q6.938-8 17.782-15.5Q748.188-800 758-803l8.5-34h37.151l8.164 33.933Q822-800 832.821-792.5q10.822 7.5 17.554 15.5L894-792l19 33-34 27.5q2 5 3.5 11.25T884-708q0 6-1.5 12.25T879-684.5l34 27.5-19 33-43.562-15q-6.938 8-17.782 15-10.844 7-20.656 11l-8.5 34h-37.151ZM785-650q25 0 41.5-16.5T843-708q0-25-16.5-41.5T785-766q-25 0-41.5 16.5T727-708q0 25 16.5 41.5T785-650ZM103.5-181.5h511-511Z"/></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -14,10 +14,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import chat.simplex.common.model.ChatController
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.defaultLocale
|
||||
import chat.simplex.common.platform.desktopPlatform
|
||||
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
|
||||
import chat.simplex.common.ui.theme.SimpleXTheme
|
||||
import chat.simplex.common.views.TerminalView
|
||||
import chat.simplex.common.views.helpers.FileDialogChooser
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.*
|
||||
import java.awt.event.WindowEvent
|
||||
import java.awt.event.WindowFocusListener
|
||||
@@ -118,6 +121,18 @@ fun showApp() = application {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reload all strings in all @Composable's after language change at runtime
|
||||
if (remember { ChatController.appPrefs.terminalAlwaysVisible.state }.value && remember { ChatController.appPrefs.appLanguage.state }.value != "") {
|
||||
var hiddenUntilRestart by remember { mutableStateOf(false) }
|
||||
if (!hiddenUntilRestart) {
|
||||
val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp)
|
||||
Window(state = cWindowState, onCloseRequest = ::exitApplication, title = stringResource(MR.strings.chat_console)) {
|
||||
SimpleXTheme {
|
||||
TerminalView(ChatModel) { hiddenUntilRestart = true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SimplexWindowState {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import boofcv.struct.image.GrayU8
|
||||
import chat.simplex.res.MR
|
||||
import org.jetbrains.skia.Image
|
||||
import java.awt.RenderingHints
|
||||
import java.awt.geom.AffineTransform
|
||||
import java.awt.image.AffineTransformOp
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.*
|
||||
import java.net.URI
|
||||
@@ -171,3 +173,37 @@ actual fun isAnimImage(uri: URI, drawable: Any?): Boolean {
|
||||
@Suppress("NewApi")
|
||||
actual fun loadImageBitmap(inputStream: InputStream): ImageBitmap =
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ enum class DesktopPlatform(val libPath: String, val libExtension: String, val co
|
||||
MAC_AARCH64("/libs/mac-aarch64", "dylib", unixConfigPath, unixDataPath);
|
||||
|
||||
fun isLinux() = this == LINUX_X86_64 || this == LINUX_AARCH64
|
||||
fun isWindows() = this == WINDOWS_X86_64
|
||||
fun isMac() = this == MAC_X86_64 || this == MAC_AARCH64
|
||||
}
|
||||
|
||||
|
||||
@@ -54,9 +54,9 @@ actual object AudioPlayer: AudioPlayerInterface {
|
||||
if (fileSource.cryptoArgs != null) {
|
||||
val tmpFile = fileSource.createTmpFileIfNeeded()
|
||||
decryptCryptoFile(absoluteFilePath, fileSource.cryptoArgs, tmpFile.absolutePath)
|
||||
player.media().prepare("file://${tmpFile.absolutePath}")
|
||||
player.media().prepare(tmpFile.toURI().toString().replaceFirst("file:", "file://"))
|
||||
} else {
|
||||
player.media().prepare("file://$absoluteFilePath")
|
||||
player.media().prepare(File(absoluteFilePath).toURI().toString().replaceFirst("file:", "file://"))
|
||||
}
|
||||
}.onFailure {
|
||||
Log.e(TAG, it.stackTraceToString())
|
||||
@@ -171,7 +171,7 @@ actual object AudioPlayer: AudioPlayerInterface {
|
||||
var res: Int? = null
|
||||
try {
|
||||
val helperPlayer = AudioPlayerComponent().mediaPlayer()
|
||||
helperPlayer.media().startPaused("file://$unencryptedFilePath")
|
||||
helperPlayer.media().startPaused(File(unencryptedFilePath).toURI().toString().replaceFirst("file:", "file://"))
|
||||
res = helperPlayer.duration
|
||||
helperPlayer.stop()
|
||||
helperPlayer.release()
|
||||
|
||||
@@ -2,17 +2,20 @@ package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.ui.graphics.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
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.EmbeddedMediaPlayerComponent
|
||||
import java.awt.Component
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.max
|
||||
|
||||
actual class VideoPlayer actual constructor(
|
||||
@@ -29,17 +32,14 @@ actual class VideoPlayer actual constructor(
|
||||
override val duration: MutableState<Long> = mutableStateOf(0L)
|
||||
override val preview: MutableState<ImageBitmap> = mutableStateOf(defaultPreview)
|
||||
|
||||
val mediaPlayerComponent = initializeMediaPlayerComponent()
|
||||
val mediaPlayerComponent by lazy { runBlocking(playerThread.asCoroutineDispatcher()) { getOrCreatePlayer() } }
|
||||
val player by lazy { mediaPlayerComponent.mediaPlayer() }
|
||||
|
||||
init {
|
||||
withBGApi {
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
setPreviewAndDuration()
|
||||
}
|
||||
|
||||
private val currentVolume: Int by lazy { player.audio().volume() }
|
||||
private var isReleased: Boolean = false
|
||||
private var isReleased: AtomicBoolean = AtomicBoolean(false)
|
||||
|
||||
private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null)
|
||||
private var progressJob: Job? = null
|
||||
@@ -48,6 +48,7 @@ actual class VideoPlayer actual constructor(
|
||||
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 {
|
||||
val filepath = getAppFilePath(uri)
|
||||
if (filepath == null || !File(filepath).exists()) {
|
||||
@@ -87,7 +88,7 @@ actual class VideoPlayer actual constructor(
|
||||
// Player can only be accessed in one specific thread
|
||||
progressJob = CoroutineScope(Dispatchers.Main).launch {
|
||||
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,
|
||||
// so help to make the playback stopped in UI immediately
|
||||
if (player.currentPosition == player.duration) {
|
||||
@@ -97,7 +98,7 @@ actual class VideoPlayer actual constructor(
|
||||
delay(50)
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PLAYING)
|
||||
}
|
||||
if (isActive && !isReleased) {
|
||||
if (isActive && !isReleased.get()) {
|
||||
onProgressUpdate(player.currentPosition.toLong(), TrackState.PAUSED)
|
||||
}
|
||||
onProgressUpdate(null, TrackState.PAUSED)
|
||||
@@ -107,9 +108,11 @@ actual class VideoPlayer actual constructor(
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (isReleased || !videoPlaying.value) return
|
||||
player.controls().stop()
|
||||
stopListener()
|
||||
if (isReleased.get() || !videoPlaying.value) return
|
||||
playerThread.execute {
|
||||
player.stop()
|
||||
stopListener()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stopListener() {
|
||||
@@ -133,45 +136,57 @@ actual class VideoPlayer actual constructor(
|
||||
if (progress.value == duration.value) {
|
||||
progress.value = 0
|
||||
}
|
||||
videoPlaying.value = start(progress.value) { pro, _ ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if ((pro == null || pro == duration.value) && duration.value != 0L) {
|
||||
videoPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
}/* else if (state == TrackState.STOPPED) {
|
||||
playerThread.execute {
|
||||
videoPlaying.value = start(progress.value) { pro, _ ->
|
||||
if (pro != null) {
|
||||
progress.value = pro
|
||||
}
|
||||
if ((pro == null || pro == duration.value) && duration.value != 0L) {
|
||||
videoPlaying.value = false
|
||||
if (pro == duration.value) {
|
||||
progress.value = if (resetOnEnd) 0 else duration.value
|
||||
}/* else if (state == TrackState.STOPPED) {
|
||||
progress.value = 0 //
|
||||
}*/
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun enableSound(enable: Boolean): Boolean {
|
||||
if (isReleased) return false
|
||||
if (soundEnabled.value == enable) return false
|
||||
// Impossible to change volume for only one player. It changes for every player
|
||||
// https://github.com/caprica/vlcj/issues/985
|
||||
return false
|
||||
/*if (isReleased.get() || soundEnabled.value == enable) return false
|
||||
soundEnabled.value = enable
|
||||
player.audio().setVolume(if (enable) currentVolume else 0)
|
||||
return true
|
||||
playerThread.execute {
|
||||
player.audio().isMute = !enable
|
||||
}
|
||||
return true*/
|
||||
}
|
||||
|
||||
override fun release(remove: Boolean) { withApi {
|
||||
if (isReleased) return@withApi
|
||||
isReleased = true
|
||||
// TODO
|
||||
/** [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 (player.isPlaying) player.stop()
|
||||
CoroutineScope(Dispatchers.IO).launch { player.release() }
|
||||
if (remove) {
|
||||
VideoPlayerHolder.players.remove(uri to gallery)
|
||||
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) {
|
||||
VideoPlayerHolder.players.remove(uri to gallery)
|
||||
}
|
||||
}
|
||||
}}
|
||||
}
|
||||
|
||||
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
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val previewAndDuration = VideoPlayerHolder.previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(defaultPreview, uri) }
|
||||
@@ -182,35 +197,79 @@ actual class VideoPlayer actual constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeMediaPlayerComponent(): Component {
|
||||
return if (desktopPlatform.isMac()) {
|
||||
CallbackMediaPlayerComponent()
|
||||
} else {
|
||||
EmbeddedMediaPlayerComponent()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Component.mediaPlayer() = when (this) {
|
||||
is CallbackMediaPlayerComponent -> mediaPlayer()
|
||||
is EmbeddedMediaPlayerComponent -> 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()
|
||||
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 {
|
||||
return if (desktopPlatform.isMac()) {
|
||||
CallbackMediaPlayerComponent()
|
||||
} else {
|
||||
EmbeddedMediaPlayerComponent()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBitmapFromVideo(defaultPreview: ImageBitmap?, uri: URI?): VideoPlayerInterface.PreviewAndDuration = withContext(playerThread.asCoroutineDispatcher()) {
|
||||
val mediaComponent = getOrCreateHelperPlayer()
|
||||
val player = mediaComponent.mediaPlayer()
|
||||
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://"))
|
||||
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)
|
||||
}
|
||||
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()
|
||||
CoroutineScope(Dispatchers.IO).launch { player.release() }
|
||||
return VideoPlayerInterface.PreviewAndDuration(preview = preview, timestamp = 0L, duration = duration)
|
||||
player.stop()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
package chat.simplex.common.views.chat.item
|
||||
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import chat.simplex.common.platform.VideoPlayer
|
||||
import chat.simplex.common.platform.isPlaying
|
||||
import chat.simplex.common.views.helpers.onRightClick
|
||||
|
||||
@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
|
||||
actual fun LocalWindowWidth(): Dp {
|
||||
|
||||
@@ -6,17 +6,15 @@ import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.awt.SwingPanel
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.simplexWindowState
|
||||
import chat.simplex.common.views.helpers.getBitmapFromByteArray
|
||||
import chat.simplex.res.MR
|
||||
import dev.icerock.moko.resources.compose.painterResource
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import kotlinx.coroutines.delay
|
||||
import org.jetbrains.compose.videoplayer.SkiaBitmapVideoSurface
|
||||
import kotlin.math.max
|
||||
|
||||
@Composable
|
||||
@@ -28,30 +26,40 @@ actual fun FullScreenImageView(modifier: Modifier, data: ByteArray, imageBitmap:
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
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(Modifier.fillMaxSize().padding(bottom = 50.dp)) {
|
||||
val factory = remember { { player.mediaPlayerComponent } }
|
||||
SwingPanel(
|
||||
background = Color.Transparent,
|
||||
modifier = Modifier,
|
||||
factory = factory
|
||||
)
|
||||
SurfaceFromPlayer(player, modifier)
|
||||
IconButton(onClick = close, Modifier.padding(top = 5.dp)) {
|
||||
Icon(painterResource(MR.images.ic_arrow_back_ios_new), null, Modifier.size(30.dp), tint = MaterialTheme.colors.primary)
|
||||
}
|
||||
}
|
||||
Controls(player, close)
|
||||
Controls(player)
|
||||
}
|
||||
}
|
||||
|
||||
@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 progress = remember(player) { player.progress }
|
||||
val duration = remember(player) { player.duration }
|
||||
@@ -62,10 +70,7 @@ private fun BoxScope.Controls(player: VideoPlayer, close: () -> Unit) {
|
||||
Slider(
|
||||
value = progress.value.toFloat() / max(0.0001f, duration.value.toFloat()),
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.Divider
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.helpers.*
|
||||
|
||||
private object NoIndication : Indication {
|
||||
private object NoIndicationInstance : IndicationInstance {
|
||||
override fun ContentDrawScope.drawIndication() {
|
||||
drawContent()
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
override fun rememberUpdatedInstance(interactionSource: InteractionSource): IndicationInstance {
|
||||
return NoIndicationInstance
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun ChatListNavLinkLayout(
|
||||
chatLinkPreview: @Composable () -> Unit,
|
||||
click: () -> Unit,
|
||||
dropdownMenuItems: (@Composable () -> Unit)?,
|
||||
showMenu: MutableState<Boolean>,
|
||||
stopped: Boolean,
|
||||
selectedChat: State<Boolean>
|
||||
) {
|
||||
var modifier = Modifier.fillMaxWidth()
|
||||
if (!stopped) modifier = modifier
|
||||
.background(color = if (selectedChat.value) MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
|
||||
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
|
||||
.onRightClick { showMenu.value = true }
|
||||
CompositionLocalProvider(
|
||||
LocalIndication provides if (selectedChat.value && !stopped) NoIndication else LocalIndication.current
|
||||
) {
|
||||
Box(modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
chatLinkPreview()
|
||||
}
|
||||
if (dropdownMenuItems != null) {
|
||||
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
@@ -10,12 +10,12 @@ import androidx.compose.ui.input.key.*
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.*
|
||||
import chat.simplex.common.DialogParams
|
||||
import chat.simplex.common.platform.desktopPlatform
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.awt.FileDialog
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import javax.swing.JFileChooser
|
||||
import javax.swing.filechooser.FileFilter
|
||||
import javax.swing.filechooser.FileNameExtensionFilter
|
||||
@@ -53,7 +53,7 @@ fun FrameWindowScope.FileDialogChooser(
|
||||
params: DialogParams,
|
||||
onResult: (result: List<File>) -> Unit
|
||||
) {
|
||||
if (isLinux()) {
|
||||
if (desktopPlatform.isLinux() || desktopPlatform.isWindows()) {
|
||||
FileDialogChooserMultiple(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, params.fileFilterDescription, onResult)
|
||||
} else {
|
||||
FileDialogAwt(title, isLoad, params.filename, params.allowMultiple, params.fileFilter, onResult)
|
||||
@@ -121,7 +121,7 @@ fun FrameWindowScope.FileDialogChooserMultiple(
|
||||
}
|
||||
|
||||
/*
|
||||
* Has graphic glitches on many Linux distributions, so use only on non-Linux systems
|
||||
* Has graphic glitches on many Linux distributions, so use only on non-Linux systems. Also file filter doesn't work on Windows
|
||||
* */
|
||||
@Composable
|
||||
private fun FrameWindowScope.FileDialogAwt(
|
||||
@@ -159,5 +159,3 @@ private fun FrameWindowScope.FileDialogAwt(
|
||||
},
|
||||
dispose = FileDialog::dispose
|
||||
)
|
||||
|
||||
fun isLinux(): Boolean = System.getProperty("os.name", "generic").lowercase(Locale.ENGLISH) == "linux"
|
||||
|
||||
@@ -88,7 +88,7 @@ actual fun escapedHtmlToAnnotatedString(text: String, density: Density): Annotat
|
||||
}
|
||||
|
||||
actual fun getAppFileUri(fileName: String): URI =
|
||||
URI("file:" + appFilesDir.absolutePath + File.separator + fileName)
|
||||
URI(appFilesDir.toURI().toString() + "/" + fileName)
|
||||
|
||||
actual fun getLoadedImage(file: CIFile?): Pair<ImageBitmap, ByteArray>? {
|
||||
val filePath = getLoadedFilePath(file)
|
||||
|
||||
@@ -63,9 +63,10 @@ compose {
|
||||
windows {
|
||||
packageName = "SimpleX"
|
||||
iconFile.set(project.file("src/jvmMain/resources/distribute/simplex.ico"))
|
||||
console = true
|
||||
perUserInstall = true
|
||||
console = false
|
||||
perUserInstall = false
|
||||
dirChooser = true
|
||||
shortcut = true
|
||||
}
|
||||
macOS {
|
||||
packageName = "SimpleX"
|
||||
@@ -119,9 +120,9 @@ cmake {
|
||||
/*machines.customMachines.register("linux-aarch64") {
|
||||
toolchainFile.set(project.file("$cppPath/toolchains/aarch64-linux-gnu-gcc.cmake"))
|
||||
}*/
|
||||
machines.customMachines.register("win-amd64") {
|
||||
/*machines.customMachines.register("win-amd64") {
|
||||
toolchainFile.set(project.file("$cppPath/toolchains/x86_64-windows-mingw32-gcc.cmake"))
|
||||
}
|
||||
}*/
|
||||
if (machines.host.name == "mac-amd64") {
|
||||
machines.customMachines.register("mac-amd64") {
|
||||
toolchainFile.set(project.file("$cppPath/toolchains/x86_64-mac-apple-darwin-gcc.cmake"))
|
||||
@@ -139,6 +140,9 @@ cmake {
|
||||
val main by creating {
|
||||
cmakeLists.set(file("$cppPath/desktop/CMakeLists.txt"))
|
||||
targetMachines.addAll(compileMachineTargets.toSet())
|
||||
if (machines.host.name.contains("win")) {
|
||||
cmakeArgs.add("-G MinGW Makefiles")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,102 +157,106 @@ afterEvaluate {
|
||||
tasks.create("cmakeBuildAndCopy") {
|
||||
dependsOn("cmakeBuild")
|
||||
val copyDetails = mutableMapOf<String, ArrayList<FileCopyDetails>>()
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-x86_64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/linux-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-aarch64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc"
|
||||
from("$cppPath/desktop/libs/linux-aarch64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/win-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/windows-x86_64")
|
||||
include("*.dll")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/windows-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-x86_64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/mac-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-aarch64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc"
|
||||
from("$cppPath/desktop/libs/mac-aarch64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
doLast {
|
||||
copyDetails.forEach { (destinationDir, details) ->
|
||||
details.forEach { detail ->
|
||||
val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path)
|
||||
if (target.exists()) {
|
||||
target.setLastModified(detail.lastModified)
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-amd64", "$cppPath/desktop/libs/linux-x86_64", "$cppPath/desktop/libs/linux-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-x86_64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/linux-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/linux-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64", "$cppPath/desktop/libs/linux-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/linux-aarch64")
|
||||
include("*.so*")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/linux-aarch64/vlc"
|
||||
from("$cppPath/desktop/libs/linux-aarch64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/windows-amd64", "$cppPath/desktop/libs/windows-x86_64", "$cppPath/desktop/libs/windows-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/windows-x86_64")
|
||||
include("*.dll")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/windows-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/windows-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64", "$cppPath/desktop/libs/mac-x86_64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-x86_64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/mac-x86_64/vlc"
|
||||
from("$cppPath/desktop/libs/mac-x86_64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
copy {
|
||||
from("${project(":desktop").buildDir}/cmake/main/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64", "$cppPath/desktop/libs/mac-aarch64/deps")
|
||||
into("src/jvmMain/resources/libs/mac-aarch64")
|
||||
include("*.dylib")
|
||||
eachFile {
|
||||
path = name
|
||||
}
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
copy {
|
||||
val destinationDir = "src/jvmMain/resources/libs/mac-aarch64/vlc"
|
||||
from("$cppPath/desktop/libs/mac-aarch64/deps/vlc")
|
||||
into(destinationDir)
|
||||
includeEmptyDirs = false
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
copyIfNeeded(destinationDir, copyDetails)
|
||||
}
|
||||
}
|
||||
afterEvaluate {
|
||||
doLast {
|
||||
copyDetails.forEach { (destinationDir, details) ->
|
||||
details.forEach { detail ->
|
||||
val target = File(projectDir.absolutePath + File.separator + destinationDir + File.separator + detail.path)
|
||||
if (target.exists()) {
|
||||
target.setLastModified(detail.lastModified)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,13 +18,15 @@ fun main() {
|
||||
|
||||
@Suppress("UnsafeDynamicallyLoadedCode")
|
||||
private fun initHaskell() {
|
||||
val libApp = "libapp-lib.${desktopPlatform.libExtension}"
|
||||
val libsTmpDir = File(tmpDir.absolutePath + File.separator + "libs")
|
||||
copyResources(desktopPlatform.libPath, libsTmpDir.toPath())
|
||||
System.load(File(libsTmpDir, libApp).absolutePath)
|
||||
|
||||
vlcDir.deleteRecursively()
|
||||
Files.move(File(libsTmpDir, "vlc").toPath(), vlcDir.toPath(), StandardCopyOption.REPLACE_EXISTING)
|
||||
if (desktopPlatform == DesktopPlatform.WINDOWS_X86_64) {
|
||||
windowsLoadRequiredLibs(libsTmpDir)
|
||||
} else {
|
||||
System.load(File(libsTmpDir, "libapp-lib.${desktopPlatform.libExtension}").absolutePath)
|
||||
}
|
||||
// No picture without preloading it, only sound. However, with libs from AppImage it works without preloading
|
||||
//val libXcb = "libvlc_xcb_events.so.0.0.0"
|
||||
//System.load(File(File(vlcDir, "vlc"), libXcb).absolutePath)
|
||||
@@ -55,3 +57,25 @@ private fun copyResources(from: String, to: Path) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun windowsLoadRequiredLibs(libsTmpDir: File) {
|
||||
val mainLibs = arrayOf(
|
||||
"libcrypto-3-x64.dll",
|
||||
"libffi-8.dll",
|
||||
"libgmp-10.dll",
|
||||
"libsimplex.dll",
|
||||
"libapp-lib.dll"
|
||||
)
|
||||
mainLibs.forEach {
|
||||
System.load(File(libsTmpDir, it).absolutePath)
|
||||
}
|
||||
val vlcLibs = arrayOf(
|
||||
"libvlccore.dll",
|
||||
"libvlc.dll",
|
||||
"axvlc.dll",
|
||||
"npvlc.dll"
|
||||
)
|
||||
vlcLibs.forEach {
|
||||
System.load(File(vlcDir, it).absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,11 @@ android.nonTransitiveRClass=true
|
||||
android.enableJetifier=true
|
||||
kotlin.mpp.androidSourceSetLayoutVersion=2
|
||||
|
||||
android.version_name=5.3
|
||||
android.version_code=152
|
||||
android.version_name=5.4-beta.0
|
||||
android.version_code=156
|
||||
|
||||
desktop.version_name=5.3
|
||||
desktop.version_code=10
|
||||
desktop.version_name=5.4-beta.0
|
||||
desktop.version_code=12
|
||||
|
||||
kotlin.version=1.8.20
|
||||
gradle.plugin.version=7.4.2
|
||||
|
||||
@@ -4,6 +4,7 @@ title: "SimpleX File Transfer Protocol - a new protocol for sending large files
|
||||
date: 2023-03-01
|
||||
preview: CLI and relays implementing the new XFTP protocol are released - you can use them now!
|
||||
image: images/20230301-xftp.jpg
|
||||
imageWide: true
|
||||
permalink: "/blog/20230301-simplex-file-transfer-protocol.html"
|
||||
---
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ To accelerate product development and growth we will be raising a seed funding t
|
||||
|
||||
### Send videos and files up to 1gb!
|
||||
|
||||
<img src="./images/20230422-video.png" width="288">
|
||||
<img src="./images/20230422-video.png" width="288" class="float-to-left">
|
||||
|
||||
In the beginning of March [we released servers and command-line utility to send and receive files via XFTP protocol](./20230301-simplex-file-transfer-protocol.md) - a very private and secure protocol that sends end-to-end encrypted files in chunks, protecting meta-data better than any alternatives we know of.
|
||||
|
||||
@@ -88,7 +88,7 @@ Now you can choose whether to use faster and more convenient system biometric au
|
||||
|
||||
### Networking improvements
|
||||
|
||||
<img src="./images/20230422-socks.png" width="288">
|
||||
<img src="./images/20230422-socks.png" width="288" class="float-to-left">
|
||||
|
||||
Two small improvements to the app networking capabilities were added in this version.
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ Also, we added Japanese and Portuguese (Brazil)<sup>*</sup> interface languages,
|
||||
|
||||
## Message reactions
|
||||
|
||||
<img src="./images/20230523-reactions.png" width="288">
|
||||
<img src="./images/20230523-reactions.png" width="288" class="float-to-left">
|
||||
|
||||
No idea why it took us so long to add them – finally we have them, and they are great.
|
||||
|
||||
@@ -50,7 +50,7 @@ The next app version will allow prohibiting the reactions per conversation, as y
|
||||
|
||||
### Voice messages: up to 5 minutes, better quality, playback control
|
||||
|
||||
<img src="./images/20230523-voice.png" width="288">
|
||||
<img src="./images/20230523-voice.png" width="288" class="float-to-left">
|
||||
|
||||
Since [v4.3](./20221206-simplex-chat-v4.3-voice-messages.md#instant-voice-messages) voice messages were sent in small 16kb chunks, so we had to limit them to 30-40 seconds for better user experience, as sending larger files would require the sender to be online.
|
||||
|
||||
@@ -66,7 +66,7 @@ This version allows to configure the time for messages to disappear more granula
|
||||
|
||||
### Message editing history
|
||||
|
||||
<img src="./images/20230523-info.png" width="288">
|
||||
<img src="./images/20230523-info.png" width="288" class="float-to-left">
|
||||
|
||||
I [wrote previously](./20221206-simplex-chat-v4.3-voice-messages.md#irreversible-message-deletion) why we decided to require the recipient concent before the messages can be fully deleted by the sender - in short, it is to support recipient's data sovereignty and prevent the possibility of offensive messages being removed without any trace. By default, when the sender deletes the message it is marked as deleted, rather than fully deleted, and you can reveal the original message.
|
||||
|
||||
@@ -74,7 +74,7 @@ You've found the workaround for it of course - it's enough to simply edit the me
|
||||
|
||||
## Customize and share color themes
|
||||
|
||||
<img src="./images/20230523-theme.png" width="288">
|
||||
<img src="./images/20230523-theme.png" width="288" class="float-to-left">
|
||||
|
||||
Android app now allows choosing between three color themes - Light, Dark and SimpleX (a dark blue theme). You can customize any theme by setting 9 different colors used in the app, including titles, menus, accent colors and colors for sent and received messages.
|
||||
|
||||
@@ -82,7 +82,7 @@ You can share your theme with other users by exporting it to a file and sending
|
||||
|
||||
## Self-destruct passcode
|
||||
|
||||
<img src="./images/20230523-self-destruct.png" width="288">
|
||||
<img src="./images/20230523-self-destruct.png" width="288" class="float-to-left">
|
||||
|
||||
This is something many of you asked before - when asked to enter the app passcode under duress, to be able to enter a special self-destruct code that would remove the app data. This feature is offered in many security tools, and now you can configure it in SimpleX Chat as well.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ permalink: "/blog/20230722-simplex-chat-v5-2-message-delivery-receipts.html"
|
||||
|
||||
### Message delivery receipts
|
||||
|
||||
<img src="./images/20230722-receipts.png" width="330">
|
||||
<img src="./images/20230722-receipts.png" width="330" class="float-to-left">
|
||||
|
||||
Most messaging apps add two ticks to sent messages – the first one to show that the message is accepted by the server, and the second – that it is delivered to the recipient's device. It confirms that the network is functioning, and that the message is not lost or delayed. SimpleX Chat now has this feature too!
|
||||
|
||||
@@ -48,7 +48,7 @@ To avoid compromising your privacy, sending delivery receipts is disabled for al
|
||||
|
||||
### Filter favorite and unread chats
|
||||
|
||||
<img src="./images/20230722-filter.png" width="288">
|
||||
<img src="./images/20230722-filter.png" width="288" class="float-to-left">
|
||||
|
||||
You can now mark your contacts and groups as _favorite_, to be able to find them faster. With filter enabled, you will only see favorite chats, chats that contain unread messages and also any unaccepted group invitations and contact requests.
|
||||
|
||||
@@ -58,13 +58,13 @@ Active SimpleX Chat users know how broken the current group experience is, and t
|
||||
|
||||
#### What is this in reply to?
|
||||
|
||||
<img src="./images/20230722-quoted.png" width="330">
|
||||
<img src="./images/20230722-quoted.png" width="330" class="float-to-left">
|
||||
|
||||
A major problem is that you can see replies to the messages you've not seen before - this would happen both when you just join the group, and didn't connect to most other members, and also when other new members join the group and they didn't yet connect to you – so literally all the time, and the bigger the group gets, the worse it becomes. While this problem cannot be solved without major group protocol changes, at least there is now ability to see the original message that was replied to via the message information.
|
||||
|
||||
#### How to connect to this member?
|
||||
|
||||
<img src="./images/20230722-search.png" width="330">
|
||||
<img src="./images/20230722-search.png" width="330" class="float-to-left">
|
||||
|
||||
To simplify direct connections with other group members, you can now share your SimpleX address via your chat profile, and group members can send you a contact request even if the group does not allow direct messages.
|
||||
|
||||
|
||||
@@ -2,14 +2,132 @@
|
||||
layout: layouts/article.html
|
||||
title: "SimpleX Chat v5.3 released: desktop app, local file encryption and improved groups with directory service"
|
||||
date: 2023-09-25
|
||||
# image: images/20230925-desktop-app.png
|
||||
# previewBody: blog_previews/20230722.html
|
||||
image: images/simplex-desktop-light.png
|
||||
imageWide: true
|
||||
previewBody: blog_previews/20230925.html
|
||||
permalink: "/blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.html"
|
||||
draft: true
|
||||
---
|
||||
|
||||
# SimpleX Chat v5.3 released: desktop app, local file encryption and improved groups
|
||||
|
||||
**Published:** September 25, 2023
|
||||
|
||||
This is a placeholder for the release announcement
|
||||
**What's new in v5.3:**
|
||||
- [new desktop app!](#multiplatform-desktop-app)!
|
||||
- [directory service and other group improvements](#group-directory-service-and-other-group-improvements).
|
||||
- [encrypted local files and media with forward secrecy](#encrypted-local-files-and-media-with-forward-secrecy).
|
||||
- [simplified incognito mode](#simplified-incognito-mode).
|
||||
|
||||
There are a lot of other improvements and fixes in this release:
|
||||
- improved app responsiveness and stability.
|
||||
- app memory usage is reduced by 40%.
|
||||
- new privacy settings: show last messages & save draft.
|
||||
- fixes:
|
||||
- bug preventing group members connecting (it will only help the new connections).
|
||||
- playing videos on full screen<sup>**</sup>.
|
||||
- screen reader for messages<sup>**</sup>.
|
||||
- reduced background crashes<sup>**</sup>.
|
||||
|
||||
Also, we added 6 new interface languages: Arabic<sup>*</sup>, Bulgarian, Finnish, Hebrew<sup>*</sup>, Thai and Ukrainian - thanks to [our users and Weblate](https://github.com/simplex-chat/simplex-chat#help-translating-simplex-chat).
|
||||
|
||||
\* Android app.
|
||||
|
||||
\*\* iOS app.
|
||||
|
||||
## Multiplatform desktop app
|
||||
|
||||
<img src="./images/simplex-desktop-light.png" width="640">
|
||||
|
||||
Thanks a lot to everybody who was testing the desktop app since July – it really helped to make it stable!
|
||||
|
||||
To use desktop app you need to **create a new profile**. As SimpleX platform has no user accounts, it's not as simple as for centralized apps to access the same profile from two devices.
|
||||
|
||||
The next app version will allow using your mobile profile from desktop app. For now, as a workaround, you can join groups from both mobile and desktop devices, and use small groups instead of direct conversations.
|
||||
|
||||
When you start the app first time, you will be offered to **set database passphrase** – you have to memorize it, as there is no way to recover it. If you skip it, a random passphrase will be generated and stored on your desktop device as plaintext (unencrypted) – you can change it later.
|
||||
|
||||
Other limitations of the desktop app:
|
||||
- you cannot send voice messages.
|
||||
- there is no support for calls yet.
|
||||
|
||||
You can download the desktop app for Linux and Mac via [downloads page](https://simplex.chat/downloads). Windows version will be available soon.
|
||||
|
||||
## Group directory service and other group improvements
|
||||
|
||||
<img src="./images/20230925-directory.png" width="330" class="float-to-left">
|
||||
|
||||
Directory service provides a way to search for public groups submitted by the users. To use it, you need to connect to it via SimpleX Chat, as you would connect to any other contact, and type some words to search.
|
||||
|
||||
You can also create and register your group, with some limitations explained [here](../docs/DIRECTORY.md).
|
||||
|
||||
Other group improvements in this release:
|
||||
|
||||
- you can send delivery receipts to the groups up to 20 members.
|
||||
|
||||
- if the group settings allow it, you can send direct messages to group members even after you deleted the contact.
|
||||
|
||||
- connections between members are made faster, and the bug that prevented the connections in some cases is fixed in this release.
|
||||
|
||||
The next release will reduce the time it takes to send messages to the group, especially when there are many members or when you have a slow device storage.
|
||||
|
||||
## Encrypted local files and media with forward secrecy
|
||||
|
||||
<img src="./images/20230925-encrypted.png" width="330" class="float-to-left">
|
||||
|
||||
All messages, files and media sent via SimpleX Chat were always end-to-end encrypted from the very beginning. SimpleX Chat uses double-ratchet algorithm with encrypted message headers, for the best possible meta-data protection.
|
||||
|
||||
You contacts, groups and messages are stored in the local database on your device, and this database was encrypted from [v4.0 released a year ago](./20220928-simplex-chat-v4-encrypted-database.md).
|
||||
|
||||
But until this version all files and media in the app storage were not encrypted, and when you exported the chat archive, they were unencrypted there as well.
|
||||
|
||||
From v5.3 all files and media (except videos, for now) are encrypted with a random symmetric key - in many cases they are encrypted before they are written to the storage. Local file encryption can be disabled via Privacy & Security settings, for example, if you need to access the files from the storage outside of the app.
|
||||
|
||||
In addition to the videos that are stored unencrypted, there are other rare scenarios when the received files may be unencrypted in this release. Files have an open or closed lock icons to indicate whether they were encrypted locally. These limitations will be addressed in the next release. In any case, all files and media are always sent end-to-end encrypted, without any exceptions.
|
||||
|
||||
The keys used to encrypt files locally are associated with the messages and stored in the encrypted database. If you delete a message with the attached file or media, the key will be irreversibly deleted as well. Even if an attacker gains access to your database passphrase later and to the copy of the encrypted file, they won't be able to decrypt the file.
|
||||
|
||||
This approach provides forward secrecy for locally stored files, unlike file encryption schemes used in some other apps when the same passphrase is used for all files.
|
||||
|
||||
## Simplified incognito mode
|
||||
|
||||
<img src="./images/20230925-incognito.png" width="330" class="float-to-left">
|
||||
|
||||
Incognito mode was [added a year ago](./20220901-simplex-chat-v3.2-incognito-mode.md) to improve anonymity of your profile, but it was confusing for some users - it was a global setting, but it only affected the new connections.
|
||||
|
||||
It is now simpler to use - you can decide whether to connect to a contact or join a group using your main profile at a point when you create an invitation link or connect via a link or QR code.
|
||||
|
||||
When you are connecting to people your know you usually want to share your main profile, and when connecting to public groups or strangers, you may prefer to use a random profile.
|
||||
|
||||
## SimpleX platform
|
||||
|
||||
Some links to answer the most common questions:
|
||||
|
||||
[SimpleX Chat security assessment](./20221108-simplex-chat-v4.2-security-audit-new-website.md).
|
||||
|
||||
[How can SimpleX deliver messages without user identifiers](https://simplex.chat/#how-simplex-works).
|
||||
|
||||
[What are the risks to have identifiers assigned to the users](https://simplex.chat/#why-ids-bad-for-privacy).
|
||||
|
||||
[Technical details and limitations](https://github.com/simplex-chat/simplex-chat#privacy-technical-details-and-limitations).
|
||||
|
||||
[How SimpleX is different from Session, Matrix, Signal, etc.](https://github.com/simplex-chat/simplex-chat/blob/stable/README.md#frequently-asked-questions).
|
||||
|
||||
Visit our [website](https://simplex.chat) to learn more.
|
||||
|
||||
## Help us with donations
|
||||
|
||||
Huge thank you to everybody who donated to SimpleX Chat!
|
||||
|
||||
We are prioritizing users privacy and security - it would be impossible without your support.
|
||||
|
||||
Our pledge to our users is that SimpleX protocols are and will remain open, and in public domain, - so anybody can build the future implementations of the clients and the servers. We are building SimpleX platform based on the same principles as email and web, but much more private and secure.
|
||||
|
||||
Your donations help us raise more funds – any amount, even the price of the cup of coffee, makes a big difference for us.
|
||||
|
||||
See [this section](https://github.com/simplex-chat/simplex-chat/tree/master#help-us-with-donations) for the ways to donate.
|
||||
|
||||
Thank you,
|
||||
|
||||
Evgeny
|
||||
|
||||
SimpleX Chat founder
|
||||
|
||||
BIN
blog/images/20230925-directory.png
Normal file
BIN
blog/images/20230925-directory.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
BIN
blog/images/20230925-encrypted.png
Normal file
BIN
blog/images/20230925-encrypted.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 475 KiB |
BIN
blog/images/20230925-incognito.png
Normal file
BIN
blog/images/20230925-incognito.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
BIN
blog/images/simplex-desktop-light.png
Normal file
BIN
blog/images/simplex-desktop-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 545 KiB |
@@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5
|
||||
tag: ec1b72cb8013a65a5d9783104a47ae44f5730089
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -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-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**
|
||||
|
||||
@@ -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.
|
||||
|
||||
3. To build Android core library:
|
||||
- merge `master` branch to `master-android` branch.
|
||||
3. To build core libraries for Android, iOS and windows:
|
||||
- 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 `simplexmq` commit in `master-android` branch to the commit in `master-ghc8107` branch.
|
||||
- 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 `master-android` (and compile/update code)
|
||||
- `master-android` to `master-ios`
|
||||
- `master` to `master-ghc8107` (and compile/update code)
|
||||
- `master-ghc8107` to `master-android`
|
||||
- `master-ghc8107` to `master-ios`
|
||||
- `master-ghc8107` to `windows-ghc8107`
|
||||
- `master-android` to `stable-android`
|
||||
- `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
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
---
|
||||
title: Download SimpleX apps
|
||||
permalink: /downloads/index.html
|
||||
revision: 20.09.2023
|
||||
revision: 01.10.2023
|
||||
---
|
||||
|
||||
| Updated 20.09.2023 | Languages: EN |
|
||||
| Updated 01.10.2023 | Languages: EN |
|
||||
# Download SimpleX apps
|
||||
|
||||
The latest stable version is v5.3.1.
|
||||
|
||||
You can get the latest beta releases from [GitHub](https://github.com/simplex-chat/simplex-chat/releases).
|
||||
|
||||
- [desktop](#desktop-app)
|
||||
- [mobile](#mobile-apps)
|
||||
- [terminal](#terminal-console-app) (console)
|
||||
@@ -15,28 +19,26 @@ revision: 20.09.2023
|
||||
|
||||
<img src="/docs/images/simplex-desktop-light.png" alt="desktop app" width=500>
|
||||
|
||||
The latest version of desktop app is v5.3-beta.9 (1.6.0 in the app).
|
||||
|
||||
Using the same profile as on mobile device is not yet supported – you need to create a separate profile to use desktop apps.
|
||||
|
||||
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-ubuntu-22_04-x86_64.deb).
|
||||
**Linux**: [AppImage](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-x86_64.AppImage) (most Linux distros), [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-20_04-x86_64.deb) (and Debian-based distros), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-ubuntu-22_04-x86_64.deb).
|
||||
|
||||
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
|
||||
**Mac**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-x86_64.dmg) (Intel), [aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-desktop-macos-aarch64.dmg) (Apple Silicon).
|
||||
|
||||
**Windows**: coming soon.
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.4.0-beta.0/simplex-desktop-windows-x86-64.msi) (BETA).
|
||||
|
||||
## Mobile apps
|
||||
|
||||
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084) (v5.2.3), [TestFlight](https://testflight.apple.com/join/DWuT2LQu) (v5.3-beta.9).
|
||||
**iOS**: [App store](https://apps.apple.com/us/app/simplex-chat/id1605771084), [TestFlight](https://testflight.apple.com/join/DWuT2LQu).
|
||||
|
||||
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-armv7a.apk).
|
||||
**Android**: [Play store](https://play.google.com/store/apps/details?id=chat.simplex.app), [F-Droid](https://simplex.chat/fdroid/), [APK aarch64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex.apk), [APK armv7](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-armv7a.apk).
|
||||
|
||||
## Terminal (console) app
|
||||
|
||||
See [Using terminal app](/docs/CLI.md).
|
||||
|
||||
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-ubuntu-22_04-x86-64).
|
||||
**Linux**: [Ubuntu 20.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-20_04-x86-64), [Ubuntu 22.04](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-ubuntu-22_04-x86-64).
|
||||
|
||||
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#).
|
||||
**Mac** [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-macos-x86-64), aarch64 - [compile from source](./CLI.md#).
|
||||
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.0-beta.9/simplex-chat-windows-x86-64).
|
||||
**Windows**: [x86_64](https://github.com/simplex-chat/simplex-chat/releases/download/v5.3.1/simplex-chat-windows-x86-64).
|
||||
|
||||
@@ -15,31 +15,30 @@ We want to add up to 3 people to the team.
|
||||
|
||||
## Who we are looking for
|
||||
|
||||
### Systems Haskell engineer
|
||||
### Application Haskell engineer
|
||||
|
||||
You are a servers/network/Haskell expert:
|
||||
- network libraries.
|
||||
You are an expert in language models, databases and Haskell:
|
||||
- expert knowledge of SQL.
|
||||
- exception handling, concurrency, STM.
|
||||
- type systems - we use ad hoc dependent types a lot.
|
||||
- strictness.
|
||||
- has some expertise in network protocols, cryptography and general information security principles and approaches.
|
||||
- experience integrating open-source language models.
|
||||
- experience developing community-centric applications.
|
||||
- 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 a product UX expert who designs great user experiences directly in iOS code:
|
||||
- iOS and Mac platforms, including:
|
||||
- SwiftUI and UIKit.
|
||||
- extensions, including notification service extension and sharing extension.
|
||||
- low level inter-process communication primitives for concurrency.
|
||||
You are an expert in Apple platforms, including:
|
||||
- iOS and Mac platform architecture.
|
||||
- Swift and Objective-C.
|
||||
- SwiftUI and UIKit.
|
||||
- extensions, including notification service extension and sharing extension.
|
||||
- low level inter-process communication primitives for concurrency.
|
||||
- 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.
|
||||
|
||||
|
||||
## About you
|
||||
|
||||
- **Passionate about joining SimpleX Chat team**:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
name: simplex-chat
|
||||
version: 5.3.0.10
|
||||
version: 5.4.0.0
|
||||
#synopsis:
|
||||
#description:
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
@@ -32,6 +32,7 @@ dependencies:
|
||||
- http-types == 0.12.*
|
||||
- memory == 0.18.*
|
||||
- mtl == 2.3.*
|
||||
- myers-diff >= 0.2.0.0
|
||||
- network >= 3.1.2.7 && < 3.2
|
||||
- optparse-applicative >= 0.15 && < 0.17
|
||||
- process == 1.6.*
|
||||
|
||||
@@ -7,7 +7,7 @@ function readlink() {
|
||||
}
|
||||
|
||||
if [ -z "${1}" ]; then
|
||||
echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download_libs.sh https://something.com/job/something/{master,stable}"
|
||||
echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-libs.sh https://something.com/job/something/{master,stable}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
OS=mac
|
||||
ARCH="${1:-`uname -a | rev | cut -d' ' -f1 | rev`}"
|
||||
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"
|
||||
|
||||
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
|
||||
#cp $GHC_LIBS_DIR/libffi.dylib ./deps
|
||||
@@ -54,7 +56,7 @@ function copy_deps() {
|
||||
|
||||
cp $LIB ./deps
|
||||
if [[ "$NON_FINAL_RPATHS" == *"@loader_path/.."* ]]; then
|
||||
# Need to point the lib to @loader_path instead
|
||||
# Need to point the lib to @loader_path instead
|
||||
install_name_tool -add_rpath @loader_path ./deps/`basename $LIB`
|
||||
fi
|
||||
#echo LIB $LIB
|
||||
@@ -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
|
||||
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 -
|
||||
|
||||
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/
|
||||
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/
|
||||
|
||||
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
|
||||
|
||||
22
scripts/desktop/build-lib-windows.sh
Executable file
22
scripts/desktop/build-lib-windows.sh
Executable file
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
function readlink() {
|
||||
echo "$(cd "$(dirname "$1")"; pwd -P)"
|
||||
}
|
||||
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
|
||||
|
||||
OS=windows
|
||||
ARCH=`uname -a | rev | cut -d' ' -f2 | rev`
|
||||
JOB_REPO=${1:-$SIMPLEX_CI_REPO_URL}
|
||||
|
||||
cd $root_dir
|
||||
|
||||
rm -rf apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
rm -rf apps/multiplatform/desktop/src/jvmMain/resources/libs/$OS-$ARCH/
|
||||
rm -rf apps/multiplatform/desktop/build/cmake
|
||||
|
||||
mkdir -p apps/multiplatform/common/src/commonMain/cpp/desktop/libs/$OS-$ARCH/
|
||||
scripts/desktop/download-lib-windows.sh $JOB_REPO
|
||||
scripts/desktop/prepare-vlc-windows.sh
|
||||
27
scripts/desktop/download-lib-windows.sh
Normal file
27
scripts/desktop/download-lib-windows.sh
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
function readlink() {
|
||||
echo "$(cd "$(dirname "$1")"; pwd -P)"
|
||||
}
|
||||
|
||||
if [ -z "${1}" ]; then
|
||||
echo "Job repo is unset. Provide it via first argument like: $(readlink "$0")/download-lib-windows.sh https://something.com/job/something/{windows,windows-8107}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
job_repo=$1
|
||||
arch=x86_64
|
||||
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
|
||||
output_dir="$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-$arch/"
|
||||
|
||||
mkdir -p "$output_dir"/deps 2> /dev/null
|
||||
|
||||
curl --location -o libsimplex.zip $job_repo/$arch-linux.$arch-windows:lib:simplex-chat/latest/download/1 && \
|
||||
$WINDIR\\System32\\tar.exe -xf libsimplex.zip && \
|
||||
mv libsimplex.dll "$output_dir" && \
|
||||
mv libcrypto*.dll "$output_dir/deps" && \
|
||||
mv libffi*.dll "$output_dir/deps" && \
|
||||
mv libgmp*.dll "$output_dir/deps" && \
|
||||
rm libsimplex.zip
|
||||
25
scripts/desktop/prepare-vlc-windows.sh
Normal file
25
scripts/desktop/prepare-vlc-windows.sh
Normal file
@@ -0,0 +1,25 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
function readlink() {
|
||||
echo "$(cd "$(dirname "$1")"; pwd -P)"
|
||||
}
|
||||
root_dir="$(dirname "$(dirname "$(readlink "$0")")")"
|
||||
vlc_dir=$root_dir/apps/multiplatform/common/src/commonMain/cpp/desktop/libs/windows-x86_64/deps/vlc
|
||||
rm -rf $vlc_dir
|
||||
mkdir -p $vlc_dir/vlc || exit 0
|
||||
|
||||
cd /tmp
|
||||
mkdir tmp 2>/dev/null || true
|
||||
cd tmp
|
||||
curl https://irltoolkit.mm.fcix.net/videolan-ftp/vlc/3.0.18/win64/vlc-3.0.18-win64.zip -L -o vlc
|
||||
$WINDIR\\System32\\tar.exe -xf vlc
|
||||
cd vlc-*
|
||||
# Setting the same date as the date that will be on the file after extraction from JAR to make VLC cache checker happy
|
||||
find plugins | grep ".dll" | xargs touch -m -d "1970-01-01T00:00:00Z"
|
||||
./vlc-cache-gen plugins
|
||||
cp *.dll $vlc_dir/
|
||||
cp -r -p plugins/ $vlc_dir/vlc/plugins
|
||||
cd ../../
|
||||
rm -rf tmp
|
||||
@@ -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/kazu-yamamoto/http2.git"."b5a1b7200cf5bc7044af34ba325284271f6dff25" = "0dqb50j57an64nf4qcf5vcz4xkd1vzvghvf8bk529c1k30r9nfzb";
|
||||
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "0kiwhvml42g9anw4d2v0zd1fpc790pj9syg5x3ik4l97fnkbbwpp";
|
||||
|
||||
@@ -5,7 +5,7 @@ cabal-version: 1.12
|
||||
-- see: https://github.com/sol/hpack
|
||||
|
||||
name: simplex-chat
|
||||
version: 5.3.0.10
|
||||
version: 5.4.0.0
|
||||
category: Web, System, Services, Cryptography
|
||||
homepage: https://github.com/simplex-chat/simplex-chat#readme
|
||||
author: simplex.chat
|
||||
@@ -34,6 +34,7 @@ library
|
||||
Simplex.Chat.Core
|
||||
Simplex.Chat.Help
|
||||
Simplex.Chat.Markdown
|
||||
Simplex.Chat.MarkdownDiff
|
||||
Simplex.Chat.Messages
|
||||
Simplex.Chat.Messages.CIContent
|
||||
Simplex.Chat.Migrations.M20220101_initial
|
||||
@@ -113,6 +114,7 @@ library
|
||||
Simplex.Chat.Migrations.M20230903_connections_to_subscribe
|
||||
Simplex.Chat.Migrations.M20230913_member_contacts
|
||||
Simplex.Chat.Migrations.M20230914_member_probes
|
||||
Simplex.Chat.Migrations.M20230926_contact_status
|
||||
Simplex.Chat.Mobile
|
||||
Simplex.Chat.Mobile.File
|
||||
Simplex.Chat.Mobile.Shared
|
||||
@@ -164,6 +166,7 @@ library
|
||||
, http-types ==0.12.*
|
||||
, memory ==0.18.*
|
||||
, mtl ==2.3.*
|
||||
, myers-diff >= 0.2.0.0
|
||||
, network >=3.1.2.7 && <3.2
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
@@ -212,6 +215,7 @@ executable simplex-bot
|
||||
, http-types ==0.12.*
|
||||
, memory ==0.18.*
|
||||
, mtl ==2.3.*
|
||||
, myers-diff >= 0.2.0.0
|
||||
, network >=3.1.2.7 && <3.2
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
@@ -261,6 +265,7 @@ executable simplex-bot-advanced
|
||||
, http-types ==0.12.*
|
||||
, memory ==0.18.*
|
||||
, mtl ==2.3.*
|
||||
, myers-diff >= 0.2.0.0
|
||||
, network >=3.1.2.7 && <3.2
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
@@ -312,6 +317,7 @@ executable simplex-broadcast-bot
|
||||
, http-types ==0.12.*
|
||||
, memory ==0.18.*
|
||||
, mtl ==2.3.*
|
||||
, myers-diff >= 0.2.0.0
|
||||
, network >=3.1.2.7 && <3.2
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
@@ -362,6 +368,7 @@ executable simplex-chat
|
||||
, http-types ==0.12.*
|
||||
, memory ==0.18.*
|
||||
, mtl ==2.3.*
|
||||
, myers-diff >= 0.2.0.0
|
||||
, network ==3.1.*
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
@@ -452,6 +459,7 @@ test-suite simplex-chat-test
|
||||
ChatTests.Profiles
|
||||
ChatTests.Utils
|
||||
MarkdownTests
|
||||
MarkdownDiffTests
|
||||
MobileTests
|
||||
ProtocolTests
|
||||
SchemaDump
|
||||
@@ -491,6 +499,7 @@ test-suite simplex-chat-test
|
||||
, http-types ==0.12.*
|
||||
, memory ==0.18.*
|
||||
, mtl ==2.3.*
|
||||
, myers-diff >= 0.2.0.0
|
||||
, network ==3.1.*
|
||||
, optparse-applicative >=0.15 && <0.17
|
||||
, process ==1.6.*
|
||||
|
||||
@@ -19,7 +19,6 @@ module Simplex.Chat where
|
||||
|
||||
import Control.Applicative (optional, (<|>))
|
||||
import Control.Concurrent.STM (retry)
|
||||
import qualified Control.Exception as E
|
||||
import Control.Logger.Simple
|
||||
import Control.Monad
|
||||
import Control.Monad.Except
|
||||
@@ -356,11 +355,6 @@ execChatCommand_ u cmd = either (CRChatCmdError u) id <$> runExceptT (processCha
|
||||
parseChatCommand :: ByteString -> Either String ChatCommand
|
||||
parseChatCommand = A.parseOnly chatCommandP . B.dropWhileEnd isSpace
|
||||
|
||||
toView :: ChatMonad' m => ChatResponse -> m ()
|
||||
toView event = do
|
||||
q <- asks outputQ
|
||||
atomically $ writeTBQueue q (Nothing, event)
|
||||
|
||||
processChatCommand :: forall m. ChatMonad m => ChatCommand -> m ChatResponse
|
||||
processChatCommand = \case
|
||||
ShowActiveUser -> withUser' $ pure . CRActiveUser
|
||||
@@ -897,14 +891,16 @@ processChatCommand = \case
|
||||
liftIO $ updateGroupUnreadChat db user groupInfo unreadChat
|
||||
ok user
|
||||
_ -> 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
|
||||
ct@Contact {localDisplayName} <- withStore $ \db -> getContact db user chatId
|
||||
filesInfo <- withStore' $ \db -> getContactFileInfo db user ct
|
||||
contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct)
|
||||
withChatLock "deleteChat direct" . procCmd $ do
|
||||
fileAgentConnIds <- concat <$> forM filesInfo (deleteFile user)
|
||||
deleteAgentConnectionsAsync user $ fileAgentConnIds <> contactConnIds
|
||||
deleteFilesAndConns user filesInfo
|
||||
when (isReady ct && contactActive ct && notify) $
|
||||
void (sendDirectContactMessage ct XDirectDel) `catchChatError` const (pure ())
|
||||
contactConnIds <- map aConnId <$> withStore (\db -> getContactConnections db userId ct)
|
||||
deleteAgentConnectionsAsync user contactConnIds
|
||||
-- functions below are called in separate transactions to prevent crashes on android
|
||||
-- (possibly, race condition on integrity check?)
|
||||
withStore' $ \db -> deleteContactConnectionsAndFiles db userId ct
|
||||
@@ -1327,7 +1323,7 @@ processChatCommand = \case
|
||||
ConnectSimplex incognito -> withUser $ \user ->
|
||||
-- [incognito] generate profile to send
|
||||
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
|
||||
APIListContacts userId -> withUserId userId $ \user ->
|
||||
CRContactsList user <$> withStore' (`getUserContacts` user)
|
||||
@@ -1422,7 +1418,7 @@ processChatCommand = \case
|
||||
processChatCommand . APISendMessage chatRef True Nothing $ ComposedMessage Nothing Nothing mc
|
||||
SendMessageBroadcast msg -> withUser $ \user -> do
|
||||
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
|
||||
withChatLock "sendMessageBroadcast" . procCmd $ do
|
||||
(successes, failures) <- foldM (sendAndCount user logLevel) (0, 0) cts
|
||||
@@ -1590,7 +1586,7 @@ processChatCommand = \case
|
||||
processChatCommand $ APILeaveGroup groupId
|
||||
DeleteGroup gName -> withUser $ \user -> do
|
||||
groupId <- withStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId)
|
||||
processChatCommand $ APIDeleteChat (ChatRef CTGroup groupId) True
|
||||
ClearGroup gName -> withUser $ \user -> do
|
||||
groupId <- withStore $ \db -> getGroupIdByName db user gName
|
||||
processChatCommand $ APIClearChat (ChatRef CTGroup groupId)
|
||||
@@ -1972,7 +1968,7 @@ processChatCommand = \case
|
||||
-- read contacts before user update to correctly merge preferences
|
||||
-- [incognito] filter out contacts with whom user has incognito connections
|
||||
contacts <-
|
||||
filter (\ct -> isReady ct && not (contactConnIncognito ct))
|
||||
filter (\ct -> isReady ct && contactActive ct && not (contactConnIncognito ct))
|
||||
<$> withStore' (`getUserContacts` user)
|
||||
user' <- updateUser
|
||||
asks currentUser >>= atomically . (`writeTVar` Just user')
|
||||
@@ -2558,7 +2554,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
|
||||
getContactConns :: m ([ConnId], Map ConnId Contact)
|
||||
getContactConns = do
|
||||
cts <- withStore_ ("subscribeUserConnections " <> show userId <> ", getUserContacts") getUserContacts
|
||||
let connIds = map contactConnId cts
|
||||
let connIds = map contactConnId (filter contactActive cts)
|
||||
pure (connIds, M.fromList $ zip connIds cts)
|
||||
getUserContactLinkConns :: m ([ConnId], Map ConnId UserContact)
|
||||
getUserContactLinkConns = do
|
||||
@@ -2568,7 +2564,7 @@ subscribeUserConnections onlyNeeded agentBatchSubscribe user@User {userId} = do
|
||||
getGroupMemberConns :: m ([Group], [ConnId], Map ConnId GroupMember)
|
||||
getGroupMemberConns = do
|
||||
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)
|
||||
getSndFileTransferConns :: m ([ConnId], Map ConnId SndFileTransfer)
|
||||
getSndFileTransferConns = do
|
||||
@@ -3034,6 +3030,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
XFileCancel sharedMsgId -> xFileCancel ct' sharedMsgId msgMeta
|
||||
XFileAcptInv sharedMsgId fileConnReq_ fName -> xFileAcptInv ct' sharedMsgId fileConnReq_ fName msgMeta
|
||||
XInfo p -> xInfo ct' p
|
||||
XDirectDel -> xDirectDel ct' msg msgMeta
|
||||
XGrpInv gInv -> processGroupInvitation ct' gInv msg msgMeta
|
||||
XInfoProbe probe -> xInfoProbe (CGMContact ct') probe
|
||||
XInfoProbeCheck probeHash -> xInfoProbeCheck ct' probeHash
|
||||
@@ -4238,6 +4235,24 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
|
||||
xInfo :: Contact -> Profile -> m ()
|
||||
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 c@Contact {profile = p} p' createItems
|
||||
| fromLocalProfile p /= p' = do
|
||||
@@ -4921,8 +4936,9 @@ deleteOrUpdateMemberRecord user@User {userId} member =
|
||||
Nothing -> deleteGroupMember db user member
|
||||
|
||||
sendDirectContactMessage :: (MsgEncodingI e, ChatMonad m) => Contact -> ChatMsgEvent e -> m (SndMessage, Int64)
|
||||
sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent
|
||||
sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}, contactStatus} chatMsgEvent
|
||||
| connStatus /= ConnReady && connStatus /= ConnSndReady = throwChatError $ CEContactNotReady ct
|
||||
| contactStatus /= CSActive = throwChatError $ CEContactNotActive ct
|
||||
| connDisabled conn = throwChatError $ CEContactDisabled ct
|
||||
| otherwise = sendDirectMessage conn chatMsgEvent (ConnectionId connId)
|
||||
|
||||
@@ -5325,33 +5341,6 @@ withAgent action =
|
||||
>>= runExceptT . action
|
||||
>>= liftEither . first (`ChatErrorAgent` Nothing)
|
||||
|
||||
withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a
|
||||
withStore' action = withStore $ liftIO . action
|
||||
|
||||
withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a
|
||||
withStore = withStoreCtx Nothing
|
||||
|
||||
withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a
|
||||
withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action
|
||||
|
||||
withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a
|
||||
withStoreCtx ctx_ action = do
|
||||
ChatController {chatStore} <- ask
|
||||
liftEitherError ChatErrorStore $ case ctx_ of
|
||||
Nothing -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal ""
|
||||
-- uncomment to debug store performance
|
||||
-- Just ctx -> do
|
||||
-- t1 <- liftIO getCurrentTime
|
||||
-- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx
|
||||
-- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")")
|
||||
-- t2 <- liftIO getCurrentTime
|
||||
-- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1)
|
||||
-- pure r
|
||||
Just _ -> withTransaction chatStore (runExceptT . action) `E.catch` handleInternal ""
|
||||
where
|
||||
handleInternal :: String -> E.SomeException -> IO (Either StoreError a)
|
||||
handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr
|
||||
|
||||
chatCommandP :: Parser ChatCommand
|
||||
chatCommandP =
|
||||
choice
|
||||
@@ -5411,7 +5400,7 @@ chatCommandP =
|
||||
"/_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)))),
|
||||
"/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP),
|
||||
"/_delete " *> (APIDeleteChat <$> chatRefP),
|
||||
"/_delete " *> (APIDeleteChat <$> chatRefP <*> (A.space *> "notify=" *> onOffP <|> pure True)),
|
||||
"/_clear chat " *> (APIClearChat <$> chatRefP),
|
||||
"/_accept" *> (APIAcceptContact <$> incognitoOnOffP <* A.space <*> A.decimal),
|
||||
"/_reject " *> (APIRejectContact <$> A.decimal),
|
||||
|
||||
@@ -21,7 +21,7 @@ import qualified Data.Text as T
|
||||
import qualified Database.SQLite3 as SQL
|
||||
import Simplex.Chat.Controller
|
||||
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 System.FilePath
|
||||
import UnliftIO.Directory
|
||||
@@ -42,9 +42,9 @@ archiveFilesFolder = "simplex_v1_files"
|
||||
exportArchive :: ChatMonad m => ArchiveConfig -> m ()
|
||||
exportArchive cfg@ArchiveConfig {archivePath, disableCompression} =
|
||||
withTempDir cfg "simplex-chat." $ \dir -> do
|
||||
StorageFiles {chatDb, agentDb, filesPath} <- storageFiles
|
||||
copyFile chatDb $ dir </> archiveChatDbFile
|
||||
copyFile agentDb $ dir </> archiveAgentDbFile
|
||||
StorageFiles {chatStore, agentStore, filesPath} <- storageFiles
|
||||
copyFile (dbFilePath chatStore) $ dir </> archiveChatDbFile
|
||||
copyFile (dbFilePath agentStore) $ dir </> archiveAgentDbFile
|
||||
forM_ filesPath $ \fp ->
|
||||
copyDirectoryFiles fp $ dir </> archiveFilesFolder
|
||||
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} =
|
||||
withTempDir cfg "simplex-chat." $ \dir -> do
|
||||
Z.withArchive archivePath $ Z.unpackInto dir
|
||||
StorageFiles {chatDb, agentDb, filesPath} <- storageFiles
|
||||
backup chatDb
|
||||
backup agentDb
|
||||
copyFile (dir </> archiveChatDbFile) chatDb
|
||||
copyFile (dir </> archiveAgentDbFile) agentDb
|
||||
fs@StorageFiles {chatStore, agentStore, filesPath} <- storageFiles
|
||||
liftIO $ closeSQLiteStore `withStores` fs
|
||||
backup `withDBs` fs
|
||||
copyFile (dir </> archiveChatDbFile) $ dbFilePath chatStore
|
||||
copyFile (dir </> archiveAgentDbFile) $ dbFilePath agentStore
|
||||
copyFiles dir filesPath
|
||||
`E.catch` \(e :: E.SomeException) -> pure [AEImport . ChatError . CEException $ show e]
|
||||
where
|
||||
@@ -94,53 +94,60 @@ copyDirectoryFiles fromDir toDir = do
|
||||
|
||||
deleteStorage :: ChatMonad m => m ()
|
||||
deleteStorage = do
|
||||
StorageFiles {chatDb, agentDb, filesPath} <- storageFiles
|
||||
removeFile chatDb
|
||||
removeFile agentDb
|
||||
mapM_ removePathForcibly filesPath
|
||||
tmpPath <- readTVarIO =<< asks tempDirectory
|
||||
mapM_ removePathForcibly tmpPath
|
||||
fs <- storageFiles
|
||||
liftIO $ closeSQLiteStore `withStores` fs
|
||||
remove `withDBs` fs
|
||||
mapM_ removeDir $ filesPath fs
|
||||
mapM_ removeDir =<< chatReadVar tempDirectory
|
||||
where
|
||||
remove f = whenM (doesFileExist f) $ removeFile f
|
||||
removeDir d = whenM (doesDirectoryExist d) $ removePathForcibly d
|
||||
|
||||
data StorageFiles = StorageFiles
|
||||
{ chatDb :: FilePath,
|
||||
chatEncrypted :: TVar Bool,
|
||||
agentDb :: FilePath,
|
||||
agentEncrypted :: TVar Bool,
|
||||
{ chatStore :: SQLiteStore,
|
||||
agentStore :: SQLiteStore,
|
||||
filesPath :: Maybe FilePath
|
||||
}
|
||||
|
||||
storageFiles :: ChatMonad m => m StorageFiles
|
||||
storageFiles = do
|
||||
ChatController {chatStore, filesFolder, smpAgent} <- ask
|
||||
let SQLiteStore {dbFilePath = chatDb, dbEncrypted = chatEncrypted} = chatStore
|
||||
SQLiteStore {dbFilePath = agentDb, dbEncrypted = agentEncrypted} = agentClientStore smpAgent
|
||||
let agentStore = agentClientStore smpAgent
|
||||
filesPath <- readTVarIO filesFolder
|
||||
pure StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted, filesPath}
|
||||
pure StorageFiles {chatStore, agentStore, filesPath}
|
||||
|
||||
sqlCipherExport :: forall m. ChatMonad m => DBEncryptionConfig -> m ()
|
||||
sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = DBEncryptionKey key'} =
|
||||
when (key /= key') $ do
|
||||
fs@StorageFiles {chatDb, chatEncrypted, agentDb, agentEncrypted} <- storageFiles
|
||||
checkFile `with` fs
|
||||
backup `with` fs
|
||||
(export chatDb chatEncrypted >> export agentDb agentEncrypted)
|
||||
`catchChatError` \e -> (restore `with` fs) >> throwError e
|
||||
fs <- storageFiles
|
||||
checkFile `withDBs` fs
|
||||
backup `withDBs` fs
|
||||
checkEncryption `withStores` fs
|
||||
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
|
||||
action `with` StorageFiles {chatDb, agentDb} = action chatDb >> action agentDb
|
||||
backup f = copyFile f (f <> ".bak")
|
||||
restore f = copyFile (f <> ".bak") f
|
||||
checkFile f = unlessM (doesFileExist f) $ throwDBError $ DBErrorNoFile f
|
||||
export f dbEnc = do
|
||||
enc <- readTVarIO dbEnc
|
||||
checkEncryption SQLiteStore {dbEncrypted} = do
|
||||
enc <- readTVarIO dbEncrypted
|
||||
when (enc && null key) $ throwDBError DBErrorEncrypted
|
||||
when (not enc && not (null key)) $ throwDBError DBErrorPlaintext
|
||||
withDB (`SQL.exec` exportSQL) DBErrorExport
|
||||
renameFile (f <> ".exported") f
|
||||
withDB (`SQL.exec` testSQL) DBErrorOpen
|
||||
atomically $ writeTVar dbEnc $ not (null key')
|
||||
exported = (<> ".exported")
|
||||
removeExported f = whenM (doesFileExist $ exported f) $ removeFile (exported f)
|
||||
moveExported SQLiteStore {dbFilePath = f, dbEncrypted} = do
|
||||
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
|
||||
withDB a err =
|
||||
liftIO (bracket (SQL.open $ T.pack f) SQL.close a $> Nothing)
|
||||
withDB f' a err =
|
||||
liftIO (bracket (SQL.open $ T.pack f') SQL.close a $> Nothing)
|
||||
`catch` checkSQLError
|
||||
`catch` (\(e :: SomeException) -> sqliteError' e)
|
||||
>>= mapM_ (throwDBError . err)
|
||||
@@ -162,7 +169,12 @@ sqlCipherExport DBEncryptionConfig {currentKey = DBEncryptionKey key, newKey = D
|
||||
keySQL key'
|
||||
<> [ "PRAGMA foreign_keys = ON;",
|
||||
"PRAGMA secure_delete = ON;",
|
||||
"PRAGMA auto_vacuum = FULL;",
|
||||
"SELECT count(*) FROM sqlite_master;"
|
||||
]
|
||||
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
|
||||
|
||||
@@ -46,7 +46,7 @@ import Simplex.Chat.Markdown (MarkdownList)
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Messages.CIContent
|
||||
import Simplex.Chat.Protocol
|
||||
import Simplex.Chat.Store (AutoAccept, StoreError, UserContactLink, UserMsgReceiptSettings)
|
||||
import Simplex.Chat.Store (AutoAccept, StoreError (..), UserContactLink, UserMsgReceiptSettings)
|
||||
import Simplex.Chat.Types
|
||||
import Simplex.Chat.Types.Preferences
|
||||
import Simplex.Messaging.Agent (AgentClient, SubscriptionsInfo)
|
||||
@@ -54,8 +54,9 @@ import Simplex.Messaging.Agent.Client (AgentLocks, ProtocolTestFailure)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig, NetworkConfig)
|
||||
import Simplex.Messaging.Agent.Lock
|
||||
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 qualified Simplex.Messaging.Agent.Store.SQLite.DB as DB
|
||||
import qualified Simplex.Messaging.Crypto as C
|
||||
import Simplex.Messaging.Crypto.File (CryptoFile (..))
|
||||
import qualified Simplex.Messaging.Crypto.File as CF
|
||||
@@ -66,7 +67,7 @@ import Simplex.Messaging.Protocol (AProtoServerWithAuth, AProtocolType, CorrId,
|
||||
import Simplex.Messaging.TMap (TMap)
|
||||
import Simplex.Messaging.Transport (simplexMQVersion)
|
||||
import Simplex.Messaging.Transport.Client (TransportHost)
|
||||
import Simplex.Messaging.Util (allFinally, catchAllErrors, tryAllErrors, (<$$>))
|
||||
import Simplex.Messaging.Util (allFinally, catchAllErrors, liftEitherError, tryAllErrors, (<$$>))
|
||||
import Simplex.Messaging.Version
|
||||
import System.IO (Handle)
|
||||
import System.Mem.Weak (Weak)
|
||||
@@ -248,7 +249,7 @@ data ChatCommand
|
||||
| APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, add :: Bool, reaction :: MsgReaction}
|
||||
| APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId))
|
||||
| APIChatUnread ChatRef Bool
|
||||
| APIDeleteChat ChatRef
|
||||
| APIDeleteChat ChatRef Bool -- `notify` flag is only applied to direct chats
|
||||
| APIClearChat ChatRef
|
||||
| APIAcceptContact IncognitoEnabled Int64
|
||||
| APIRejectContact Int64
|
||||
@@ -491,6 +492,7 @@ data ChatResponse
|
||||
| CRContactUpdated {user :: User, fromContact :: Contact, toContact :: Contact}
|
||||
| CRContactsMerged {user :: User, intoContact :: Contact, mergedContact :: Contact}
|
||||
| CRContactDeleted {user :: User, contact :: Contact}
|
||||
| CRContactDeletedByContact {user :: User, contact :: Contact}
|
||||
| CRChatCleared {user :: User, chatInfo :: AChatInfo}
|
||||
| CRUserContactLinkCreated {user :: User, connReqContact :: ConnReqContact}
|
||||
| CRUserContactLinkDeleted {user :: User}
|
||||
@@ -887,6 +889,7 @@ data ChatErrorType
|
||||
| CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String}
|
||||
| CEContactNotFound {contactName :: ContactName, suspectedMember :: Maybe (GroupInfo, GroupMember)}
|
||||
| CEContactNotReady {contact :: Contact}
|
||||
| CEContactNotActive {contact :: Contact}
|
||||
| CEContactDisabled {contact :: Contact}
|
||||
| CEConnectionDisabled {connection :: Connection}
|
||||
| CEGroupUserRole {groupInfo :: GroupInfo, requiredRole :: GroupMemberRole}
|
||||
@@ -967,6 +970,15 @@ instance ToJSON SQLiteError where
|
||||
throwDBError :: ChatMonad m => DatabaseError -> m ()
|
||||
throwDBError = throwError . ChatErrorDatabase
|
||||
|
||||
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 = (ChatMonad' m, MonadError ChatError m)
|
||||
@@ -1006,11 +1018,34 @@ unsetActive a = asks activeTo >>= atomically . (`modifyTVar` unset)
|
||||
where
|
||||
unset a' = if a == a' then ActiveNone else a'
|
||||
|
||||
data ArchiveError
|
||||
= AEImport {chatError :: ChatError}
|
||||
| AEImportFile {file :: String, chatError :: ChatError}
|
||||
deriving (Show, Exception, Generic)
|
||||
toView :: ChatMonad' m => ChatResponse -> m ()
|
||||
toView event = do
|
||||
q <- asks outputQ
|
||||
atomically $ writeTBQueue q (Nothing, event)
|
||||
|
||||
instance ToJSON ArchiveError where
|
||||
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "AE"
|
||||
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "AE"
|
||||
withStore' :: ChatMonad m => (DB.Connection -> IO a) -> m a
|
||||
withStore' action = withStore $ liftIO . action
|
||||
|
||||
withStore :: ChatMonad m => (DB.Connection -> ExceptT StoreError IO a) -> m a
|
||||
withStore = withStoreCtx Nothing
|
||||
|
||||
withStoreCtx' :: ChatMonad m => Maybe String -> (DB.Connection -> IO a) -> m a
|
||||
withStoreCtx' ctx_ action = withStoreCtx ctx_ $ liftIO . action
|
||||
|
||||
withStoreCtx :: ChatMonad m => Maybe String -> (DB.Connection -> ExceptT StoreError IO a) -> m a
|
||||
withStoreCtx ctx_ action = do
|
||||
ChatController {chatStore} <- ask
|
||||
liftEitherError ChatErrorStore $ case ctx_ of
|
||||
Nothing -> withTransaction chatStore (runExceptT . action) `catch` handleInternal ""
|
||||
-- uncomment to debug store performance
|
||||
-- Just ctx -> do
|
||||
-- t1 <- liftIO getCurrentTime
|
||||
-- putStrLn $ "withStoreCtx start :: " <> show t1 <> " :: " <> ctx
|
||||
-- r <- withTransactionCtx ctx_ chatStore (runExceptT . action) `E.catch` handleInternal (" (" <> ctx <> ")")
|
||||
-- t2 <- liftIO getCurrentTime
|
||||
-- putStrLn $ "withStoreCtx end :: " <> show t2 <> " :: " <> ctx <> " :: duration=" <> show (diffToMilliseconds $ diffUTCTime t2 t1)
|
||||
-- pure r
|
||||
Just _ -> withTransaction chatStore (runExceptT . action) `catch` handleInternal ""
|
||||
where
|
||||
handleInternal :: String -> SomeException -> IO (Either StoreError a)
|
||||
handleInternal ctxStr e = pure . Left . SEInternalError $ show e <> ctxStr
|
||||
|
||||
@@ -17,7 +17,7 @@ import qualified Data.Attoparsec.Text as A
|
||||
import Data.Char (isDigit)
|
||||
import Data.Either (fromRight)
|
||||
import Data.Functor (($>))
|
||||
import Data.List (intercalate, foldl')
|
||||
import Data.List (foldl', intercalate)
|
||||
import Data.List.NonEmpty (NonEmpty)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Data.Maybe (fromMaybe, isNothing)
|
||||
@@ -51,8 +51,16 @@ data Format
|
||||
| SimplexLink {linkType :: SimplexLinkType, simplexUri :: Text, trustedUri :: Bool, smpHosts :: NonEmpty Text}
|
||||
| Email
|
||||
| Phone
|
||||
| Edited EditAction Format
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
data EditAction = EAInsert | EADelete | EAChangeFormat
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
instance ToJSON EditAction where
|
||||
toJSON = J.genericToJSON . enumJSON $ dropPrefix "EA"
|
||||
toEncoding = J.genericToEncoding . enumJSON $ dropPrefix "EA"
|
||||
|
||||
data SimplexLinkType = XLContact | XLInvitation | XLGroup
|
||||
deriving (Eq, Show, Generic)
|
||||
|
||||
@@ -129,7 +137,7 @@ parseMaybeMarkdownList s
|
||||
| otherwise = Just . reverse $ foldl' acc [] ml
|
||||
where
|
||||
ml = intercalate ["\n"] . map (markdownToList . parseMarkdown) $ T.lines s
|
||||
acc [] m = [m]
|
||||
acc [] m = [m]
|
||||
acc ms@(FormattedText f t : ms') ft@(FormattedText f' t')
|
||||
| f == f' = FormattedText f (t <> t') : ms'
|
||||
| otherwise = ft : ms
|
||||
@@ -170,14 +178,14 @@ markdownP = mconcat <$> A.many' fragmentP
|
||||
md :: Char -> Format -> Text -> Markdown
|
||||
md c f s
|
||||
| T.null s || T.head s == ' ' || T.last s == ' ' =
|
||||
unmarked $ c `T.cons` s `T.snoc` c
|
||||
unmarked $ c `T.cons` s `T.snoc` c
|
||||
| otherwise = markdown f s
|
||||
secretP :: Parser Markdown
|
||||
secretP = secret <$> A.takeWhile (== '#') <*> A.takeTill (== '#') <*> A.takeWhile (== '#')
|
||||
secret :: Text -> Text -> Text -> Markdown
|
||||
secret b s a
|
||||
| T.null a || T.null s || T.head s == ' ' || T.last s == ' ' =
|
||||
unmarked $ '#' `T.cons` ss
|
||||
unmarked $ '#' `T.cons` ss
|
||||
| otherwise = markdown Secret $ T.init ss
|
||||
where
|
||||
ss = b <> s <> a
|
||||
@@ -218,8 +226,8 @@ markdownP = mconcat <$> A.many' fragmentP
|
||||
wordMD s
|
||||
| T.null s = unmarked s
|
||||
| isUri s = case strDecode $ encodeUtf8 s of
|
||||
Right cReq -> markdown (simplexUriFormat cReq) s
|
||||
_ -> markdown Uri s
|
||||
Right cReq -> markdown (simplexUriFormat cReq) s
|
||||
_ -> markdown Uri s
|
||||
| isEmail s = markdown Email s
|
||||
| otherwise = unmarked s
|
||||
isUri s = T.length s >= 10 && any (`T.isPrefixOf` s) ["http://", "https://", "simplex:/"]
|
||||
|
||||
136
src/Simplex/Chat/MarkdownDiff.hs
Normal file
136
src/Simplex/Chat/MarkdownDiff.hs
Normal file
@@ -0,0 +1,136 @@
|
||||
{-# LANGUAGE DuplicateRecordFields #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}
|
||||
|
||||
{-# HLINT ignore "Use newtype instead of data" #-}
|
||||
|
||||
module Simplex.Chat.MarkdownDiff
|
||||
( DiffChar (..),
|
||||
DiffPlainChar (..),
|
||||
DiffFormatStatus (..),
|
||||
FormatChar (..),
|
||||
diff,
|
||||
plainDiff,
|
||||
)
|
||||
where
|
||||
|
||||
import qualified Data.Diff.Myers as D
|
||||
import qualified Data.Foldable as F
|
||||
import Data.Map.Strict (Map)
|
||||
import qualified Data.Map.Strict as M
|
||||
import Data.Sequence (Seq (..), (><))
|
||||
import qualified Data.Sequence as S
|
||||
import qualified Data.Text as T
|
||||
import Simplex.Chat.Markdown (EditAction (..), Format)
|
||||
|
||||
data DiffFormatStatus
|
||||
= UnchangedFormat
|
||||
| ChangedToFormat (Maybe Format)
|
||||
deriving (Show, Eq)
|
||||
|
||||
data DiffChar = DiffChar FormatChar (Maybe EditAction)
|
||||
deriving (Show, Eq)
|
||||
|
||||
data DiffPlainChar = DiffPlainChar Char (Maybe EditAction)
|
||||
deriving (Show, Eq)
|
||||
|
||||
data FormatChar = FormatChar
|
||||
{ char :: Char,
|
||||
format :: Maybe Format
|
||||
}
|
||||
deriving (Show, Eq)
|
||||
|
||||
newtype DeleteIndices = DeleteIndices (Seq Int) deriving (Show, Eq)
|
||||
|
||||
newtype InsertIndices = InsertIndices (Seq Int) deriving (Show, Eq)
|
||||
|
||||
plainDiff :: T.Text -> T.Text -> Seq DiffPlainChar
|
||||
plainDiff left right = toPlain <$> formattedDiff
|
||||
where
|
||||
formattedDiff = diff (toFormatted left) (toFormatted right)
|
||||
toPlain :: DiffChar -> DiffPlainChar
|
||||
toPlain (DiffChar (FormatChar c _) editAction) = DiffPlainChar c editActionPlain
|
||||
where
|
||||
editActionPlain = case editAction of
|
||||
Just EAInsert -> Just EAInsert
|
||||
Just EADelete -> Just EADelete
|
||||
Just EAChangeFormat -> Nothing
|
||||
Nothing -> Nothing
|
||||
|
||||
toFormatted :: T.Text -> Seq FormatChar
|
||||
toFormatted = fmap (`FormatChar` Nothing) . S.fromList . T.unpack
|
||||
|
||||
diff :: Seq FormatChar -> Seq FormatChar -> Seq DiffChar
|
||||
diff left right = addInserts markDeletesAndUnchangedChars
|
||||
where
|
||||
edits = D.diffTexts (toText left) (toText right)
|
||||
(DeleteIndices deleteIndicies, InsertIndices insertIndicies) = indices
|
||||
|
||||
toText :: Seq FormatChar -> T.Text
|
||||
toText = T.pack . F.toList . fmap char
|
||||
|
||||
indices :: (DeleteIndices, InsertIndices)
|
||||
indices = F.foldl' f (DeleteIndices S.empty, InsertIndices S.empty) edits
|
||||
where
|
||||
f :: (DeleteIndices, InsertIndices) -> D.Edit -> (DeleteIndices, InsertIndices)
|
||||
f (x@(DeleteIndices ds), y@(InsertIndices is)) e = case e of
|
||||
D.EditDelete m n -> (x', y) where x' = DeleteIndices $ ds >< S.fromList [m .. n]
|
||||
D.EditInsert _ m n -> (x, y') where y' = InsertIndices $ is >< S.fromList [m .. n]
|
||||
|
||||
unchangedChars :: Map Int DiffFormatStatus -- indexed in left
|
||||
unchangedChars = F.foldl' f mempty unchangedCharPairs
|
||||
where
|
||||
unchangedCharPairs :: Seq (Int, FormatChar, FormatChar)
|
||||
unchangedCharPairs = g <$> S.zip leftWithoutDeletes rightWithoutInserts
|
||||
|
||||
leftWithoutDeletes :: Seq (Int, FormatChar)
|
||||
leftWithoutDeletes =
|
||||
S.filter (\(i, _) -> i `notElem` deleteIndicies) $
|
||||
S.zip (S.fromList [0 .. S.length left - 1]) left
|
||||
|
||||
rightWithoutInserts :: Seq (Int, FormatChar)
|
||||
rightWithoutInserts =
|
||||
S.filter (\(i, _) -> i `notElem` insertIndicies) $
|
||||
S.zip (S.fromList [0 .. S.length right - 1]) right
|
||||
|
||||
f :: Map Int DiffFormatStatus -> (Int, FormatChar, FormatChar) -> Map Int DiffFormatStatus
|
||||
f acc (i, FormatChar _ fL, FormatChar _ fR) = M.insert i x acc
|
||||
where
|
||||
x = if fL == fR then UnchangedFormat else ChangedToFormat fR
|
||||
|
||||
g :: ((Int, FormatChar), (Int, FormatChar)) -> (Int, FormatChar, FormatChar)
|
||||
g ((i, c), (_, d)) = (i, c, d)
|
||||
|
||||
markDeletesAndUnchangedChars :: Seq DiffChar
|
||||
markDeletesAndUnchangedChars = S.mapWithIndex f left
|
||||
where
|
||||
f :: Int -> FormatChar -> DiffChar
|
||||
f i x@(FormatChar c _)
|
||||
| i `elem` deleteIndicies = DiffChar x (Just EADelete)
|
||||
| otherwise = case unchangedChars M.! i of -- should never error
|
||||
UnchangedFormat -> DiffChar x Nothing
|
||||
ChangedToFormat f' -> DiffChar (FormatChar c f') (Just EAChangeFormat)
|
||||
addInserts :: Seq DiffChar -> Seq DiffChar
|
||||
addInserts base = F.foldr f base edits -- start from end and work backwards, hence foldr
|
||||
where
|
||||
f :: D.Edit -> Seq DiffChar -> Seq DiffChar
|
||||
f e acc = case e of
|
||||
D.EditDelete _ _ -> acc
|
||||
D.EditInsert i m n -> S.take i' acc >< inserts >< S.drop i' acc
|
||||
where
|
||||
-- D.EditInsert i m n -> S.take i acc >< inserts >< S.drop i acc
|
||||
-- if ok to have inserts before deletes, use i not i'
|
||||
-- Using i of course is faster, but perhaps i' approach can be optimised
|
||||
|
||||
i' = slidePastDeleteBlock i
|
||||
|
||||
slidePastDeleteBlock :: Int -> Int
|
||||
slidePastDeleteBlock x = case S.lookup x acc of
|
||||
Nothing -> x
|
||||
Just (DiffChar _ diffStatus) ->
|
||||
if diffStatus == Just EADelete
|
||||
then slidePastDeleteBlock (x + 1)
|
||||
else x
|
||||
|
||||
rightFormatChars = S.take (n - m + 1) $ S.drop m right
|
||||
inserts = fmap (`DiffChar` Just EAInsert) rightFormatChars
|
||||
@@ -132,6 +132,7 @@ data CIContent (d :: MsgDirection) where
|
||||
CIRcvDecryptionError :: MsgDecryptError -> Word32 -> CIContent 'MDRcv
|
||||
CIRcvGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDRcv
|
||||
CISndGroupInvitation :: CIGroupInvitation -> GroupMemberRole -> CIContent 'MDSnd
|
||||
CIRcvDirectEvent :: RcvDirectEvent -> CIContent 'MDRcv
|
||||
CIRcvGroupEvent :: RcvGroupEvent -> CIContent 'MDRcv
|
||||
CISndGroupEvent :: SndGroupEvent -> CIContent 'MDSnd
|
||||
CIRcvConnEvent :: RcvConnEvent -> CIContent 'MDRcv
|
||||
@@ -179,6 +180,7 @@ ciRequiresAttention content = case msgDirection @d of
|
||||
CIRcvIntegrityError _ -> True
|
||||
CIRcvDecryptionError {} -> True
|
||||
CIRcvGroupInvitation {} -> True
|
||||
CIRcvDirectEvent _ -> False
|
||||
CIRcvGroupEvent rge -> case rge of
|
||||
RGEMemberAdded {} -> False
|
||||
RGEMemberConnected -> False
|
||||
@@ -300,6 +302,27 @@ instance ToJSON DBSndConnEvent where
|
||||
toJSON (SCE v) = J.genericToJSON (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
|
||||
|
||||
instance FromJSON DBMsgErrorType where
|
||||
@@ -348,6 +371,7 @@ ciContentToText = \case
|
||||
CIRcvDecryptionError err n -> msgDecryptErrorText err n
|
||||
CIRcvGroupInvitation groupInvitation memberRole -> "received " <> ciGroupInvitationToText groupInvitation memberRole
|
||||
CISndGroupInvitation groupInvitation memberRole -> "sent " <> ciGroupInvitationToText groupInvitation memberRole
|
||||
CIRcvDirectEvent event -> rcvDirectEventToText event
|
||||
CIRcvGroupEvent event -> rcvGroupEventToText event
|
||||
CISndGroupEvent event -> sndGroupEventToText event
|
||||
CIRcvConnEvent event -> rcvConnEventToText event
|
||||
@@ -368,6 +392,10 @@ ciGroupInvitationToText :: CIGroupInvitation -> GroupMemberRole -> Text
|
||||
ciGroupInvitationToText CIGroupInvitation {groupProfile = GroupProfile {displayName, fullName}} 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 = \case
|
||||
RGEMemberAdded _ p -> "added " <> profileToText p
|
||||
@@ -486,6 +514,7 @@ data JSONCIContent
|
||||
| JCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32}
|
||||
| JCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
|
||||
| JCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
|
||||
| JCIRcvDirectEvent {rcvDirectEvent :: RcvDirectEvent}
|
||||
| JCIRcvGroupEvent {rcvGroupEvent :: RcvGroupEvent}
|
||||
| JCISndGroupEvent {sndGroupEvent :: SndGroupEvent}
|
||||
| JCIRcvConnEvent {rcvConnEvent :: RcvConnEvent}
|
||||
@@ -522,6 +551,7 @@ jsonCIContent = \case
|
||||
CIRcvDecryptionError err n -> JCIRcvDecryptionError err n
|
||||
CIRcvGroupInvitation groupInvitation memberRole -> JCIRcvGroupInvitation {groupInvitation, memberRole}
|
||||
CISndGroupInvitation groupInvitation memberRole -> JCISndGroupInvitation {groupInvitation, memberRole}
|
||||
CIRcvDirectEvent rcvDirectEvent -> JCIRcvDirectEvent {rcvDirectEvent}
|
||||
CIRcvGroupEvent rcvGroupEvent -> JCIRcvGroupEvent {rcvGroupEvent}
|
||||
CISndGroupEvent sndGroupEvent -> JCISndGroupEvent {sndGroupEvent}
|
||||
CIRcvConnEvent rcvConnEvent -> JCIRcvConnEvent {rcvConnEvent}
|
||||
@@ -550,6 +580,7 @@ aciContentJSON = \case
|
||||
JCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n
|
||||
JCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole
|
||||
JCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole
|
||||
JCIRcvDirectEvent {rcvDirectEvent} -> ACIContent SMDRcv $ CIRcvDirectEvent rcvDirectEvent
|
||||
JCIRcvGroupEvent {rcvGroupEvent} -> ACIContent SMDRcv $ CIRcvGroupEvent rcvGroupEvent
|
||||
JCISndGroupEvent {sndGroupEvent} -> ACIContent SMDSnd $ CISndGroupEvent sndGroupEvent
|
||||
JCIRcvConnEvent {rcvConnEvent} -> ACIContent SMDRcv $ CIRcvConnEvent rcvConnEvent
|
||||
@@ -579,6 +610,7 @@ data DBJSONCIContent
|
||||
| DBJCIRcvDecryptionError {msgDecryptError :: MsgDecryptError, msgCount :: Word32}
|
||||
| DBJCIRcvGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
|
||||
| DBJCISndGroupInvitation {groupInvitation :: CIGroupInvitation, memberRole :: GroupMemberRole}
|
||||
| DBJCIRcvDirectEvent {rcvDirectEvent :: DBRcvDirectEvent}
|
||||
| DBJCIRcvGroupEvent {rcvGroupEvent :: DBRcvGroupEvent}
|
||||
| DBJCISndGroupEvent {sndGroupEvent :: DBSndGroupEvent}
|
||||
| DBJCIRcvConnEvent {rcvConnEvent :: DBRcvConnEvent}
|
||||
@@ -615,6 +647,7 @@ dbJsonCIContent = \case
|
||||
CIRcvDecryptionError err n -> DBJCIRcvDecryptionError err n
|
||||
CIRcvGroupInvitation groupInvitation memberRole -> DBJCIRcvGroupInvitation {groupInvitation, memberRole}
|
||||
CISndGroupInvitation groupInvitation memberRole -> DBJCISndGroupInvitation {groupInvitation, memberRole}
|
||||
CIRcvDirectEvent rde -> DBJCIRcvDirectEvent $ RDE rde
|
||||
CIRcvGroupEvent rge -> DBJCIRcvGroupEvent $ RGE rge
|
||||
CISndGroupEvent sge -> DBJCISndGroupEvent $ SGE sge
|
||||
CIRcvConnEvent rce -> DBJCIRcvConnEvent $ RCE rce
|
||||
@@ -643,6 +676,7 @@ aciContentDBJSON = \case
|
||||
DBJCIRcvDecryptionError err n -> ACIContent SMDRcv $ CIRcvDecryptionError err n
|
||||
DBJCIRcvGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDRcv $ CIRcvGroupInvitation groupInvitation memberRole
|
||||
DBJCISndGroupInvitation {groupInvitation, memberRole} -> ACIContent SMDSnd $ CISndGroupInvitation groupInvitation memberRole
|
||||
DBJCIRcvDirectEvent (RDE rde) -> ACIContent SMDRcv $ CIRcvDirectEvent rde
|
||||
DBJCIRcvGroupEvent (RGE rge) -> ACIContent SMDRcv $ CIRcvGroupEvent rge
|
||||
DBJCISndGroupEvent (SGE sge) -> ACIContent SMDSnd $ CISndGroupEvent sge
|
||||
DBJCIRcvConnEvent (RCE rce) -> ACIContent SMDRcv $ CIRcvConnEvent rce
|
||||
|
||||
18
src/Simplex/Chat/Migrations/M20230926_contact_status.hs
Normal file
18
src/Simplex/Chat/Migrations/M20230926_contact_status.hs
Normal 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;
|
||||
|]
|
||||
@@ -71,6 +71,7 @@ CREATE TABLE contacts(
|
||||
contact_group_member_id INTEGER
|
||||
REFERENCES group_members(group_member_id) ON DELETE SET NULL,
|
||||
contact_grp_inv_sent INTEGER NOT NULL DEFAULT 0,
|
||||
contact_status TEXT NOT NULL DEFAULT 'active',
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
|
||||
@@ -215,6 +215,7 @@ data ChatMsgEvent (e :: MsgEncoding) where
|
||||
XFileCancel :: SharedMsgId -> ChatMsgEvent 'Json
|
||||
XInfo :: Profile -> ChatMsgEvent 'Json
|
||||
XContact :: Profile -> Maybe XContactId -> ChatMsgEvent 'Json
|
||||
XDirectDel :: ChatMsgEvent 'Json
|
||||
XGrpInv :: GroupInvitation -> ChatMsgEvent 'Json
|
||||
XGrpAcpt :: MemberId -> ChatMsgEvent 'Json
|
||||
XGrpMemNew :: MemberInfo -> ChatMsgEvent 'Json
|
||||
@@ -550,6 +551,7 @@ data CMEventTag (e :: MsgEncoding) where
|
||||
XFileCancel_ :: CMEventTag 'Json
|
||||
XInfo_ :: CMEventTag 'Json
|
||||
XContact_ :: CMEventTag 'Json
|
||||
XDirectDel_ :: CMEventTag 'Json
|
||||
XGrpInv_ :: CMEventTag 'Json
|
||||
XGrpAcpt_ :: CMEventTag 'Json
|
||||
XGrpMemNew_ :: CMEventTag 'Json
|
||||
@@ -596,6 +598,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where
|
||||
XFileCancel_ -> "x.file.cancel"
|
||||
XInfo_ -> "x.info"
|
||||
XContact_ -> "x.contact"
|
||||
XDirectDel_ -> "x.direct.del"
|
||||
XGrpInv_ -> "x.grp.inv"
|
||||
XGrpAcpt_ -> "x.grp.acpt"
|
||||
XGrpMemNew_ -> "x.grp.mem.new"
|
||||
@@ -643,6 +646,7 @@ instance StrEncoding ACMEventTag where
|
||||
"x.file.cancel" -> XFileCancel_
|
||||
"x.info" -> XInfo_
|
||||
"x.contact" -> XContact_
|
||||
"x.direct.del" -> XDirectDel_
|
||||
"x.grp.inv" -> XGrpInv_
|
||||
"x.grp.acpt" -> XGrpAcpt_
|
||||
"x.grp.mem.new" -> XGrpMemNew_
|
||||
@@ -686,6 +690,7 @@ toCMEventTag msg = case msg of
|
||||
XFileCancel _ -> XFileCancel_
|
||||
XInfo _ -> XInfo_
|
||||
XContact _ _ -> XContact_
|
||||
XDirectDel -> XDirectDel_
|
||||
XGrpInv _ -> XGrpInv_
|
||||
XGrpAcpt _ -> XGrpAcpt_
|
||||
XGrpMemNew _ -> XGrpMemNew_
|
||||
@@ -782,6 +787,7 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
XFileCancel_ -> XFileCancel <$> p "msgId"
|
||||
XInfo_ -> XInfo <$> p "profile"
|
||||
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
|
||||
XDirectDel_ -> pure XDirectDel
|
||||
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
|
||||
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
|
||||
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
|
||||
@@ -839,6 +845,7 @@ chatToAppMessage ChatMessage {chatVRange, msgId, chatMsgEvent} = case encoding @
|
||||
XFileCancel sharedMsgId -> o ["msgId" .= sharedMsgId]
|
||||
XInfo profile -> o ["profile" .= profile]
|
||||
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
|
||||
XDirectDel -> JM.empty
|
||||
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
|
||||
XGrpAcpt memId -> o ["memberId" .= memId]
|
||||
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]
|
||||
|
||||
@@ -71,19 +71,19 @@ getConnectionEntity db user@User {userId, userContactId} agentConnId = do
|
||||
db
|
||||
[sql|
|
||||
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
|
||||
FROM contacts c
|
||||
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
|
||||
|]
|
||||
(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' contactId activeConn [(profileId, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] =
|
||||
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, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)] =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
|
||||
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"
|
||||
getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
|
||||
getGroupAndMember_ groupMemberId c = ExceptT $ do
|
||||
|
||||
@@ -42,6 +42,7 @@ module Simplex.Chat.Store.Direct
|
||||
deletePCCIncognitoProfile,
|
||||
updateContactUsed,
|
||||
updateContactUnreadChat,
|
||||
updateContactStatus,
|
||||
updateGroupUnreadChat,
|
||||
setConnectionVerified,
|
||||
incConnectionAuthErrCounter,
|
||||
@@ -147,7 +148,7 @@ getConnReqContactXContactId db user@User {userId} cReqHash = do
|
||||
[sql|
|
||||
SELECT
|
||||
-- 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,
|
||||
-- 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,
|
||||
@@ -206,7 +207,7 @@ createDirectContact db user@User {userId} activeConn@Connection {connId, localAl
|
||||
let profile = toLocalProfile profileId p localAlias
|
||||
userPreferences = emptyChatPrefs
|
||||
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, contactUsed = False, 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 userId Contact {contactId} = do
|
||||
@@ -387,6 +388,19 @@ updateContactUnreadChat db User {userId} Contact {contactId} unreadChat = do
|
||||
updatedAt <- getCurrentTime
|
||||
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 User {userId} GroupInfo {groupId} unreadChat = do
|
||||
updatedAt <- getCurrentTime
|
||||
@@ -491,7 +505,7 @@ createOrUpdateContactRequest db user@User {userId} userContactLinkId invId (Vers
|
||||
[sql|
|
||||
SELECT
|
||||
-- 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,
|
||||
-- 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,
|
||||
@@ -637,7 +651,7 @@ createAcceptedContact db user@User {userId, profile = LocalProfile {preferences}
|
||||
contactId <- insertedRowId db
|
||||
activeConn <- createConnection_ db userId ConnContact (Just contactId) agentConnId cReqChatVRange Nothing (Just userContactLinkId) customUserProfileId 0 createdAt subMode
|
||||
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
pure $ Contact {contactId, localDisplayName, profile = toLocalProfile profileId profile "", activeConn, viaGroup = Nothing, contactUsed = False, 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 User {userId} cName =
|
||||
@@ -655,7 +669,7 @@ getContact_ db user@User {userId} contactId deleted =
|
||||
[sql|
|
||||
SELECT
|
||||
-- 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,
|
||||
-- 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,
|
||||
|
||||
@@ -700,7 +700,7 @@ getContactViaMember db user@User {userId} GroupMember {groupMemberId} =
|
||||
[sql|
|
||||
SELECT
|
||||
-- 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,
|
||||
-- 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,
|
||||
@@ -1044,7 +1044,7 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
|
||||
db
|
||||
[sql|
|
||||
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,
|
||||
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,
|
||||
@@ -1062,13 +1062,13 @@ getViaGroupContact db user@User {userId} GroupMember {groupMemberId} =
|
||||
|]
|
||||
(userId, groupMemberId)
|
||||
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, localDisplayName, displayName, fullName, image, contactLink, localAlias, viaGroup, contactUsed) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) =
|
||||
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, contactStatus) :. (enableNtfs_, sendRcpts, favorite, preferences, userPreferences, createdAt, updatedAt, chatTs, contactGroupMemberId, contactGrpInvSent)) :. connRow) =
|
||||
let profile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
|
||||
activeConn = toConnection connRow
|
||||
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 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
|
||||
contactIds <-
|
||||
map fromOnly <$> case image of
|
||||
Just img -> DB.query db (q <> " AND p.image = ?") (userId, contactId, displayName, fullName, img)
|
||||
Nothing -> DB.query db (q <> " AND p.image is NULL") (userId, contactId, displayName, fullName)
|
||||
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, CSActive, displayName, fullName)
|
||||
rights <$> mapM (runExceptT . getContact db user) contactIds
|
||||
where
|
||||
-- this query is different from one in getMatchingMemberContacts
|
||||
@@ -1172,7 +1172,7 @@ getMatchingContacts db user@User {userId} Contact {contactId, profile = LocalPro
|
||||
FROM contacts ct
|
||||
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_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 = ?
|
||||
|]
|
||||
|
||||
@@ -1521,7 +1521,7 @@ createMemberContact
|
||||
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}
|
||||
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 user contactId = do
|
||||
@@ -1558,7 +1558,7 @@ createMemberContactInvited
|
||||
contactId <- createContactUpdateMember currentTs userPreferences
|
||||
ctConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
|
||||
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn
|
||||
mCt' = Contact {contactId, localDisplayName = memberLDN, profile = memberProfile, activeConn = ctConn, viaGroup = Nothing, contactUsed = True, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, contactGroupMemberId = Just groupMemberId, 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}
|
||||
pure (mCt', m')
|
||||
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
|
||||
updateConnectionStatus db oldContactConn ConnDeleted
|
||||
activeConn <- createMemberContactConn_ db user connIds gInfo mConn contactId subMode
|
||||
ct' <- resetMemberContactFields db ct
|
||||
pure (ct' :: Contact) {activeConn}
|
||||
ct' <- updateContactStatus db user ct CSActive
|
||||
ct'' <- resetMemberContactFields db ct'
|
||||
pure (ct'' :: Contact) {activeConn}
|
||||
|
||||
resetMemberContactFields :: DB.Connection -> Contact -> IO Contact
|
||||
resetMemberContactFields db ct@Contact {contactId} = do
|
||||
|
||||
@@ -478,7 +478,7 @@ getDirectChatPreviews_ db user@User {userId} = do
|
||||
[sql|
|
||||
SELECT
|
||||
-- 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,
|
||||
-- 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,
|
||||
|
||||
@@ -81,6 +81,7 @@ import Simplex.Chat.Migrations.M20230829_connections_chat_vrange
|
||||
import Simplex.Chat.Migrations.M20230903_connections_to_subscribe
|
||||
import Simplex.Chat.Migrations.M20230913_member_contacts
|
||||
import Simplex.Chat.Migrations.M20230914_member_probes
|
||||
import Simplex.Chat.Migrations.M20230926_contact_status
|
||||
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -161,7 +162,8 @@ schemaMigrations =
|
||||
("20230829_connections_chat_vrange", m20230829_connections_chat_vrange, Just down_m20230829_connections_chat_vrange),
|
||||
("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),
|
||||
("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes)
|
||||
("20230914_member_probes", m20230914_member_probes, Just down_m20230914_member_probes),
|
||||
("20230926_contact_status", m20230926_contact_status, Just down_m20230926_contact_status)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -241,24 +241,24 @@ deleteUnusedIncognitoProfileById_ db User {userId} 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 (((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}
|
||||
activeConn = toConnection connRow
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
|
||||
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 (((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}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe True enableNtfs_, sendRcpts, favorite}
|
||||
in case toMaybeConnection connRow of
|
||||
Just activeConn ->
|
||||
let mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito activeConn
|
||||
in Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, contactUsed, 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
|
||||
|
||||
getProfileById :: DB.Connection -> UserId -> Int64 -> ExceptT StoreError IO LocalProfile
|
||||
|
||||
@@ -169,6 +169,7 @@ data Contact = Contact
|
||||
activeConn :: Connection,
|
||||
viaGroup :: Maybe Int64,
|
||||
contactUsed :: Bool,
|
||||
contactStatus :: ContactStatus,
|
||||
chatSettings :: ChatSettings,
|
||||
userPreferences :: Preferences,
|
||||
mergedPreferences :: ContactUserPreferences,
|
||||
@@ -185,7 +186,7 @@ instance ToJSON Contact where
|
||||
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
|
||||
|
||||
contactConn :: Contact -> Connection
|
||||
contactConn Contact{activeConn} = activeConn
|
||||
contactConn Contact {activeConn} = activeConn
|
||||
|
||||
contactConnId :: Contact -> ConnId
|
||||
contactConnId = aConnId . contactConn
|
||||
@@ -205,9 +206,34 @@ directOrUsed ct@Contact {contactUsed} =
|
||||
anyDirectOrUsed :: Contact -> Bool
|
||||
anyDirectOrUsed Contact {contactUsed, activeConn = Connection {connLevel}} = connLevel == 0 || contactUsed
|
||||
|
||||
contactActive :: Contact -> Bool
|
||||
contactActive Contact {contactStatus} = contactStatus == CSActive
|
||||
|
||||
contactSecurityCode :: Contact -> Maybe SecurityCode
|
||||
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
|
||||
{ contactId :: ContactId,
|
||||
connId :: Int64,
|
||||
|
||||
@@ -151,6 +151,7 @@ responseToView user_ ChatConfig {logLevel, showReactions, showReceipts, testView
|
||||
CRSentConfirmation u -> ttyUser u ["confirmation sent!"]
|
||||
CRSentInvitation u customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView
|
||||
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
|
||||
CRAcceptingContactRequest u c -> ttyUser u [ttyFullContact c <> ": accepting contact request..."]
|
||||
CRContactAlreadyExists u c -> ttyUser u [ttyFullContact c <> ": contact already exists"]
|
||||
@@ -1567,6 +1568,7 @@ viewChatError logLevel = \case
|
||||
]
|
||||
CEContactNotFound cName m_ -> viewContactNotFound cName m_
|
||||
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)]
|
||||
CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning]
|
||||
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
|
||||
|
||||
@@ -49,7 +49,7 @@ extra-deps:
|
||||
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
|
||||
# - ../simplexmq
|
||||
- github: simplex-chat/simplexmq
|
||||
commit: 8d47f690838371bc848e4b31a4b09ef6bf67ccc5
|
||||
commit: ec1b72cb8013a65a5d9783104a47ae44f5730089
|
||||
- github: kazu-yamamoto/http2
|
||||
commit: b5a1b7200cf5bc7044af34ba325284271f6dff25
|
||||
# - ../direct-sqlcipher
|
||||
|
||||
@@ -31,6 +31,7 @@ chatDirectTests = do
|
||||
describe "direct messages" $ do
|
||||
describe "add contact and send/receive message" testAddContact
|
||||
it "deleting contact deletes profile" testDeleteContactDeletesProfile
|
||||
it "unused contact is deleted silently" testDeleteUnusedContactSilent
|
||||
it "direct message quoted replies" testDirectMessageQuotedReply
|
||||
it "direct message update" testDirectMessageUpdate
|
||||
it "direct message edit history" testDirectMessageEditHistory
|
||||
@@ -156,11 +157,12 @@ testAddContact = versionTestMatrix2 runTestAddContact
|
||||
-- test deleting contact
|
||||
alice ##> "/d bob_1"
|
||||
alice <## "bob_1: contact is deleted"
|
||||
bob <## "alice_1 (Alice) deleted contact with you"
|
||||
alice ##> "@bob_1 hey"
|
||||
alice <## "no contact bob_1"
|
||||
alice @@@ [("@bob", "how are you?")]
|
||||
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"]
|
||||
-- test clearing chat
|
||||
alice #$> ("/clear bob", id, "bob: all messages are removed locally ONLY")
|
||||
@@ -202,6 +204,7 @@ testDeleteContactDeletesProfile =
|
||||
-- alice deletes contact, profile is deleted
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
alice ##> "/_contacts 1"
|
||||
(alice </)
|
||||
alice `hasContactProfiles` ["alice"]
|
||||
@@ -212,6 +215,42 @@ testDeleteContactDeletesProfile =
|
||||
(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 =
|
||||
testChat2 aliceProfile bobProfile $
|
||||
@@ -514,7 +553,7 @@ testRepeatAuthErrorsDisableContact =
|
||||
connectUsers alice bob
|
||||
alice <##> bob
|
||||
threadDelay 500000
|
||||
bob ##> "/d alice"
|
||||
bob ##> "/_delete @2 notify=off"
|
||||
bob <## "alice: contact is deleted"
|
||||
forM_ [1 .. authErrDisableCount] $ \_ -> sendAuth alice
|
||||
alice <## "[bob] connection is disabled, to enable: /enable bob, to delete: /d bob"
|
||||
|
||||
@@ -575,6 +575,7 @@ testSendImage =
|
||||
-- deleting contact without files folder set should not remove file
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
alice <## "bob (Bob) deleted contact with you"
|
||||
fileExists <- doesFileExist "./tests/tmp/test.jpg"
|
||||
fileExists `shouldBe` True
|
||||
|
||||
@@ -637,6 +638,7 @@ testFilesFoldersSendImage =
|
||||
checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
alice <## "bob (Bob) deleted contact with you"
|
||||
|
||||
testFilesFoldersImageSndDelete :: HasCallStack => FilePath -> IO ()
|
||||
testFilesFoldersImageSndDelete =
|
||||
@@ -660,6 +662,7 @@ testFilesFoldersImageSndDelete =
|
||||
checkActionDeletesFile "./tests/tmp/alice_app_files/test_1MB.pdf" $ do
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
bob ##> "/fs 1"
|
||||
bob <##. "receiving file 1 (test_1MB.pdf) progress"
|
||||
-- deleting contact should remove cancelled file
|
||||
@@ -689,7 +692,10 @@ testFilesFoldersImageRcvDelete =
|
||||
checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do
|
||||
bob ##> "/d alice"
|
||||
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 <## "sending file 1 (test.jpg) cancelled: bob"
|
||||
alice <## "file transfer cancelled"
|
||||
|
||||
@@ -220,6 +220,7 @@ testGroupShared alice bob cath checkMessages = do
|
||||
-- delete contact
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
alice `send` "@bob hey"
|
||||
alice
|
||||
<### [ "@bob hey",
|
||||
@@ -234,7 +235,7 @@ testGroupShared alice bob cath checkMessages = do
|
||||
alice <# "#team bob> received"
|
||||
when checkMessages $ do
|
||||
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
|
||||
threadDelay 1000000
|
||||
alice #$> ("/clear #team", id, "#team: all messages are removed locally ONLY")
|
||||
@@ -629,6 +630,7 @@ testGroupDeleteInvitedContact =
|
||||
threadDelay 500000
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
bob ##> "/j team"
|
||||
concurrently_
|
||||
(alice <## "#team: bob joined the group")
|
||||
@@ -700,10 +702,11 @@ testDeleteGroupMemberProfileKept =
|
||||
-- delete contact
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
alice ##> "@bob hey"
|
||||
alice <## "no contact bob, use @#club bob <your message>"
|
||||
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 hey"
|
||||
bob <## "alice: not ready"
|
||||
(alice </)
|
||||
-- delete group 1
|
||||
alice ##> "/d #team"
|
||||
@@ -2785,6 +2788,8 @@ testMemberContactMessage =
|
||||
-- alice and bob delete contacts, connect
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
|
||||
@@ -2893,6 +2898,7 @@ testMemberContactInvitedConnectionReplaced tmp = do
|
||||
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## "alice (Alice) deleted contact with you"
|
||||
|
||||
alice ##> "@#team bob hi"
|
||||
alice
|
||||
@@ -2910,7 +2916,7 @@ testMemberContactInvitedConnectionReplaced tmp = do
|
||||
(alice <## "bob (Bob): 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
|
||||
subscriptions bob 1
|
||||
|
||||
@@ -558,6 +558,7 @@ testConnectIncognitoInvitationLink = testChat3 aliceProfile bobProfile cathProfi
|
||||
-- alice deletes contact, incognito profile is deleted
|
||||
alice ##> ("/d " <> bobIncognito)
|
||||
alice <## (bobIncognito <> ": contact is deleted")
|
||||
bob <## (aliceIncognito <> " deleted contact with you")
|
||||
alice ##> "/contacts"
|
||||
alice <## "cath (Catherine)"
|
||||
alice `hasContactProfiles` ["alice", "cath"]
|
||||
@@ -601,6 +602,7 @@ testConnectIncognitoContactAddress = testChat2 aliceProfile bobProfile $
|
||||
-- delete contact, incognito profile is deleted
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
alice <## (bobIncognito <> " deleted contact with you")
|
||||
bob ##> "/contacts"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
@@ -633,6 +635,7 @@ testAcceptContactRequestIncognito = testChat3 aliceProfile bobProfile cathProfil
|
||||
-- delete contact, incognito profile is deleted
|
||||
alice ##> "/d bob"
|
||||
alice <## "bob: contact is deleted"
|
||||
bob <## (aliceIncognitoBob <> " deleted contact with you")
|
||||
alice ##> "/contacts"
|
||||
(alice </)
|
||||
alice `hasContactProfiles` ["alice"]
|
||||
@@ -1063,6 +1066,7 @@ testDeleteContactThenGroupDeletesIncognitoProfile = testChat2 aliceProfile bobPr
|
||||
-- delete contact
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
alice <## (bobIncognito <> " deleted contact with you")
|
||||
bob ##> "/contacts"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["alice", "bob", T.pack bobIncognito]
|
||||
@@ -1125,6 +1129,7 @@ testDeleteGroupThenContactDeletesIncognitoProfile = testChat2 aliceProfile bobPr
|
||||
-- delete contact
|
||||
bob ##> "/d alice"
|
||||
bob <## "alice: contact is deleted"
|
||||
alice <## (bobIncognito <> " deleted contact with you")
|
||||
bob ##> "/contacts"
|
||||
(bob </)
|
||||
bob `hasContactProfiles` ["bob"]
|
||||
|
||||
334
tests/MarkdownDiffTests.hs
Normal file
334
tests/MarkdownDiffTests.hs
Normal file
@@ -0,0 +1,334 @@
|
||||
{-# LANGUAGE BlockArguments #-}
|
||||
{-# LANGUAGE OverloadedLists #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
|
||||
module MarkdownDiffTests where
|
||||
|
||||
import qualified Data.List.NonEmpty as NE
|
||||
import Simplex.Chat.Markdown
|
||||
import Simplex.Chat.MarkdownDiff
|
||||
import System.Console.ANSI.Types
|
||||
import Test.Hspec
|
||||
|
||||
markdownDiffTests :: Spec
|
||||
markdownDiffTests = do
|
||||
formattedEditedTextTests
|
||||
|
||||
formattedEditedTextTests :: Spec
|
||||
formattedEditedTextTests = describe "show edits" do
|
||||
it "empty no change" $
|
||||
diff [] [] `shouldBe` []
|
||||
it "no change" $
|
||||
diff [FormatChar 'H' Nothing] [FormatChar 'H' Nothing]
|
||||
`shouldBe` [DiffChar (FormatChar 'H' Nothing) Nothing]
|
||||
it "add 1 char to empty" $
|
||||
diff [] [FormatChar 'H' Nothing]
|
||||
`shouldBe` [DiffChar (FormatChar 'H' Nothing) $ Just EAInsert]
|
||||
it "del the one and only" $
|
||||
diff [FormatChar 'H' Nothing] []
|
||||
`shouldBe` [DiffChar (FormatChar 'H' Nothing) $ Just EADelete]
|
||||
it "one character change" do
|
||||
diff
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'r' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'o' Nothing
|
||||
]
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'e' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'o' Nothing
|
||||
]
|
||||
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'l' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'l' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'o' Nothing) Nothing
|
||||
]
|
||||
|
||||
it "more1" do
|
||||
diff
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'r' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'o' Nothing
|
||||
]
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'e' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'o' Nothing,
|
||||
FormatChar 'x' Nothing,
|
||||
FormatChar 'y' Nothing,
|
||||
FormatChar 'z' Nothing
|
||||
]
|
||||
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'l' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'l' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'o' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'z' Nothing) $ Just EAInsert
|
||||
]
|
||||
|
||||
it "more2" do
|
||||
diff
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'r' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'o' Nothing
|
||||
]
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'e' Nothing,
|
||||
FormatChar 'x' Nothing,
|
||||
FormatChar 'y' Nothing,
|
||||
FormatChar 'z' Nothing,
|
||||
FormatChar 'o' Nothing
|
||||
]
|
||||
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'z' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'o' Nothing) Nothing
|
||||
]
|
||||
|
||||
it "more3" do
|
||||
diff
|
||||
[ FormatChar 'H' $ Just Bold,
|
||||
FormatChar 'H' $ Just Bold,
|
||||
FormatChar 'r' Nothing,
|
||||
FormatChar 'l' $ Just Secret,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'o' $ Just $ colored Green
|
||||
]
|
||||
[ FormatChar 'H' $ Just Italic,
|
||||
FormatChar 'H' $ Just Bold,
|
||||
FormatChar 'e' $ Just $ colored Cyan,
|
||||
FormatChar 'x' Nothing,
|
||||
FormatChar 'y' Nothing,
|
||||
FormatChar 'z' $ Just Secret,
|
||||
FormatChar 'o' $ Just $ colored Blue
|
||||
]
|
||||
`shouldBe` [ DiffChar (FormatChar 'H' (Just Italic)) (Just EAChangeFormat),
|
||||
DiffChar (FormatChar 'H' (Just Bold)) Nothing,
|
||||
DiffChar (FormatChar 'r' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'l' (Just Secret)) $ Just EADelete,
|
||||
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'e' (Just $ colored Cyan)) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'z' (Just Secret)) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'o' (Just $ colored Blue)) (Just EAChangeFormat)
|
||||
]
|
||||
|
||||
it "more4" do
|
||||
diff
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'r' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar '~' Nothing,
|
||||
FormatChar '!' Nothing,
|
||||
FormatChar '@' Nothing,
|
||||
FormatChar 'l' Nothing,
|
||||
FormatChar 'o' Nothing
|
||||
]
|
||||
[ FormatChar 'H' Nothing,
|
||||
FormatChar 'e' Nothing,
|
||||
FormatChar 'r' Nothing,
|
||||
FormatChar 'x' Nothing,
|
||||
FormatChar 'y' Nothing,
|
||||
FormatChar '!' Nothing,
|
||||
FormatChar '@' Nothing,
|
||||
FormatChar 'z' Nothing,
|
||||
FormatChar 'o' Nothing,
|
||||
FormatChar '1' Nothing,
|
||||
FormatChar '2' Nothing
|
||||
]
|
||||
`shouldBe` [ DiffChar (FormatChar 'H' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'e' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'r' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar '~' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'x' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'y' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar '!' Nothing) Nothing,
|
||||
DiffChar (FormatChar '@' Nothing) Nothing,
|
||||
DiffChar (FormatChar 'l' Nothing) $ Just EADelete,
|
||||
DiffChar (FormatChar 'z' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar 'o' Nothing) Nothing,
|
||||
DiffChar (FormatChar '1' Nothing) $ Just EAInsert,
|
||||
DiffChar (FormatChar '2' Nothing) $ Just EAInsert
|
||||
]
|
||||
|
||||
it "SimplexLink 1" do
|
||||
diff
|
||||
[ FormatChar '>' $
|
||||
Just $
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/2/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host1", "host2", "host3"]
|
||||
}
|
||||
]
|
||||
[ FormatChar '>' $
|
||||
Just
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/3/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host0", "host2", "host3"]
|
||||
}
|
||||
]
|
||||
`shouldBe` [ DiffChar
|
||||
( FormatChar '>' $
|
||||
Just
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/3/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host0", "host2", "host3"]
|
||||
}
|
||||
)
|
||||
(Just EAChangeFormat)
|
||||
]
|
||||
|
||||
it "SimplexLink 2" do
|
||||
diff
|
||||
[ FormatChar '>' $
|
||||
Just $
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/2/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host1", "host2", "host3"]
|
||||
}
|
||||
]
|
||||
[ FormatChar '>' $
|
||||
Just
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/3/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host1", "host2", "host3"]
|
||||
}
|
||||
]
|
||||
`shouldBe` [ DiffChar
|
||||
( FormatChar '>' $
|
||||
Just
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/3/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host1", "host2", "host3"]
|
||||
}
|
||||
)
|
||||
(Just EAChangeFormat)
|
||||
]
|
||||
|
||||
it "SimplexLink 3" do
|
||||
diff
|
||||
[ FormatChar '>' $
|
||||
Just $
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/2/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host1", "host2", "host3"]
|
||||
}
|
||||
]
|
||||
[ FormatChar '>' $
|
||||
Just
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/2/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host0", "host2", "host3"]
|
||||
}
|
||||
]
|
||||
`shouldBe` [ DiffChar
|
||||
( FormatChar '>' $
|
||||
Just
|
||||
SimplexLink
|
||||
{ linkType = XLContact,
|
||||
simplexUri = "https://api.twitter.com/2/tweets/:id",
|
||||
trustedUri = True,
|
||||
smpHosts = NE.fromList ["host0", "host2", "host3"]
|
||||
}
|
||||
)
|
||||
(Just EAChangeFormat)
|
||||
]
|
||||
|
||||
it "plainDiff 1" do
|
||||
plainDiff
|
||||
"https://api.twitter.com/2/tweets/:id"
|
||||
"https://api.twitter.com/3/tweets/:id"
|
||||
`shouldBe` [ DiffPlainChar 'h' Nothing,
|
||||
DiffPlainChar 't' Nothing,
|
||||
DiffPlainChar 't' Nothing,
|
||||
DiffPlainChar 'p' Nothing,
|
||||
DiffPlainChar 's' Nothing,
|
||||
DiffPlainChar ':' Nothing,
|
||||
DiffPlainChar '/' Nothing,
|
||||
DiffPlainChar '/' Nothing,
|
||||
DiffPlainChar 'a' Nothing,
|
||||
DiffPlainChar 'p' Nothing,
|
||||
DiffPlainChar 'i' Nothing,
|
||||
DiffPlainChar '.' Nothing,
|
||||
DiffPlainChar 't' Nothing,
|
||||
DiffPlainChar 'w' Nothing,
|
||||
DiffPlainChar 'i' Nothing,
|
||||
DiffPlainChar 't' Nothing,
|
||||
DiffPlainChar 't' Nothing,
|
||||
DiffPlainChar 'e' Nothing,
|
||||
DiffPlainChar 'r' Nothing,
|
||||
DiffPlainChar '.' Nothing,
|
||||
DiffPlainChar 'c' Nothing,
|
||||
DiffPlainChar 'o' Nothing,
|
||||
DiffPlainChar 'm' Nothing,
|
||||
DiffPlainChar '/' Nothing,
|
||||
DiffPlainChar '2' $ Just EADelete,
|
||||
DiffPlainChar '3' $ Just EAInsert,
|
||||
DiffPlainChar '/' Nothing,
|
||||
DiffPlainChar 't' Nothing,
|
||||
DiffPlainChar 'w' Nothing,
|
||||
DiffPlainChar 'e' Nothing,
|
||||
DiffPlainChar 'e' Nothing,
|
||||
DiffPlainChar 't' Nothing,
|
||||
DiffPlainChar 's' Nothing,
|
||||
DiffPlainChar '/' Nothing,
|
||||
DiffPlainChar ':' Nothing,
|
||||
DiffPlainChar 'i' Nothing,
|
||||
DiffPlainChar 'd' Nothing
|
||||
]
|
||||
|
||||
it "plainDiff 2" do
|
||||
plainDiff
|
||||
"Hrl~!@lo"
|
||||
"Herxy!@zo12"
|
||||
`shouldBe` [ DiffPlainChar 'H' Nothing,
|
||||
DiffPlainChar 'e' $ Just EAInsert,
|
||||
DiffPlainChar 'r' Nothing,
|
||||
DiffPlainChar 'l' $ Just EADelete,
|
||||
DiffPlainChar '~' $ Just EADelete,
|
||||
DiffPlainChar 'x' $ Just EAInsert,
|
||||
DiffPlainChar 'y' $ Just EAInsert,
|
||||
DiffPlainChar '!' Nothing,
|
||||
DiffPlainChar '@' Nothing,
|
||||
DiffPlainChar 'l' $ Just EADelete,
|
||||
DiffPlainChar 'z' $ Just EAInsert,
|
||||
DiffPlainChar 'o' Nothing,
|
||||
DiffPlainChar '1' $ Just EAInsert,
|
||||
DiffPlainChar '2' $ Just EAInsert
|
||||
]
|
||||
@@ -5,6 +5,7 @@ import ChatTests
|
||||
import ChatTests.Utils (xdescribe'')
|
||||
import Control.Logger.Simple
|
||||
import Data.Time.Clock.System
|
||||
import MarkdownDiffTests
|
||||
import MarkdownTests
|
||||
import MobileTests
|
||||
import ProtocolTests
|
||||
@@ -20,6 +21,7 @@ main = do
|
||||
withGlobalLogging logCfg . hspec $ do
|
||||
describe "Schema dump" schemaDumpTest
|
||||
describe "SimpleX chat markdown" markdownTests
|
||||
fdescribe "SimpleX chat markdown diff" markdownDiffTests
|
||||
describe "SimpleX chat view" viewTests
|
||||
describe "SimpleX chat protocol" protocolTests
|
||||
describe "WebRTC encryption" webRTCTests
|
||||
|
||||
14
website/src/_includes/blog_previews/20230925.html
Normal file
14
website/src/_includes/blog_previews/20230925.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<p><strong>v5.3 is released:</strong></p>
|
||||
|
||||
<ul class="mb-[12px]">
|
||||
<li>new desktop app! 💻</li>
|
||||
<li>encrypt locally stored files & media</li>
|
||||
<li>directory service and other group improvements</li>
|
||||
<li>simplified incognito mode</li>
|
||||
<li>better app responsiveness, stability and 40% reduced memory usage.</li>
|
||||
<li>new privacy settings: show last messages & save draft.</li>
|
||||
</ul>
|
||||
|
||||
<p>Also, our users added 6 new interface languages - Arabic*, Bulgarian, Finnish, Hebrew*, Thai and Ukrainian.</p>
|
||||
|
||||
<p>* Android app only.</p>
|
||||
@@ -44,7 +44,9 @@ active_blog: true
|
||||
<div class="min-h-[inherit] h-full w-full flex items-end px-4 pt-4 justify-center relative">
|
||||
{% if blog.data.image %}
|
||||
{% if blog.data.imageBottom %}
|
||||
<img class="w-full max-w-[240px] h-auto" src="{{ blog.data.image }}" alt="" srcset=""/>
|
||||
<img class="w-full max-w-[240px] h-auto" src="{{ blog.data.image }}" alt="" srcset=""/>
|
||||
{% elif blog.data.imageWide %}
|
||||
<img class="mb-4 self-center w-full h-auto" src="{{ blog.data.image }}" alt="" srcset=""/>
|
||||
{% else %}
|
||||
<img class="mb-4 self-center w-full max-w-[240px] h-auto" src="{{ blog.data.image }}" alt="" srcset=""/>
|
||||
{% endif %}
|
||||
|
||||
@@ -188,29 +188,26 @@ h3::before {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
p,
|
||||
a,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
li,
|
||||
ul,
|
||||
ol,
|
||||
span,
|
||||
div,
|
||||
blockquote,
|
||||
pre,
|
||||
code {
|
||||
h6{
|
||||
clear: both;
|
||||
}
|
||||
|
||||
#article p img {
|
||||
float: left;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#article img {
|
||||
margin-bottom: 1.5rem;
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
#article .float-to-left{
|
||||
float: left;
|
||||
margin-right: 3rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user