diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9d3e4d2246..9bf5ba8711 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @epoberezkin @efim-poberezkin +* @epoberezkin @jr-simplex diff --git a/README.md b/README.md index 0884573f3a..30c565f574 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ SimpleX logo -# SimpleX - the first chat platform that is 100% private by design - it has no access to your connection graph! +# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! [![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild) [![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases) @@ -19,280 +19,120 @@ [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk) - πŸ–² Protects your messages and metadata - who you talk to and when. -- πŸ” Double ratchet encryption. -- πŸ“± Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220308-simplex-chat-mobile-apps.md). +- πŸ” Double ratchet end-to-end encryption, with additional encryption layer. +- πŸ“± Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). - πŸš€ [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**! -- πŸ–₯ Available as a [terminal (console) app / CLI](#zap-quick-installation-of-a-terminal-app) on Linux, MacOS, Windows. +- πŸ–₯ Available as a terminal (console) app / CLI on Linux, MacOS, Windows. -See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. +## Why privacy of communications matter -### :zap: Quick installation of a terminal app +Everyone should care about privacy and security of their communications - innocuous conversations can put you in danger even if there is nothing to hide. + +One of the most shocking stories is the experience of [Mohamedou Ould Salahi](https://en.wikipedia.org/wiki/Mohamedou_Ould_Slahi) that he wrote about in his memoir and that is shown in The Mauritanian movie. He was put into Guantanamo camp, without trial, and was tortured there for 15 years after a phone call to his relative in Afghanistan, under suspicion of being involved in 9/11 attacks, even though he lived in Germany for the 10 years prior to the attacks. + +It is not enough to use an end-to-end encrypted messenger, we all should use the messengers that protect the privacy of our personal networks - who we are connected with. + +## SimpleX unique approach to privacy and security + +### Full privacy of your identity, profile, contacts and metadata + +**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - not even random numbers. This protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. [Read more](./docs/SIMPLEX.md#full-privacy-of-your-identity-profile-contacts-and-metadata). + +### The best protection against spam and abuse + +As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. [Read more](./docs/SIMPLEX.md#the-best-protection-against-spam-and-abuse). + +### Complete ownership, control and security of your data + +SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. [Read more](./docs/SIMPLEX.md#complete-ownership-control-and-security-of-your-data). + +### Users own SimpleX network + +You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. [Read more](./docs/SIMPLEX.md#users-own-simplex-network). + +## For developers + +We plan that the SimpleX platform will grow into the platform supporting any distributed Internet application. This will allow you to build any service that people can access via chat, with custom web-based UI widgets that anybody with basic HTML/CSS/JavaScript knowledge can create in a few hours. + +You already can: + +- use SimpleX Chat library to integrate chat functionality into your apps. +- use SimpleX Chat bot templates in Haskell to build your own chat bot services (TypeScript SDK is coming soon). + +If you are considering developing with SimpleX platform please get in touch for any advice and support. + +## News and updates + +[Apr 04, 2022. Instant notifications for SimpleX Chat mobile apps](./blog/20220404-simplex-chat-instant-notifications.md). We would really appreciate any feedback on the design we are implementing. + +[Mar 08, 2022 Mobile apps for iOS and Android released](./blog/20220308-simplex-chat-mobile-apps.md) + +[Feb 14, 2022. SimpleX Chat: join our public beta for iOS](./blog/20220214-simplex-chat-ios-public-beta.md) + +[All updates](./blog) + +## Make a private connection + +You need to share a link or scan a QR code (in person or during a video call) to make a connection and start messaging. + +The channel through which you share the link does not have to be secure - it is enough that you can confirm who sent you the message and that your SimpleX connection is established. + +Make a private connection + +## :zap: Quick installation of a terminal app ```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash ``` Once the chat client is installed, simply run `simplex-chat` from your terminal. ![simplex-chat](./images/connection.gif) -## Table of contents +Read more about [installing and using the terminal app](./docs/CLI.md). -- [Disclaimer](#disclaimer) -- [Network topology](#network-topology) -- [Terminal chat features](#terminal-chat-features) -- [Installation](#πŸš€-installation) - - [Download chat client](#download-chat-client) - - [Linux and MacOS](#linux-and-macos) - - [Windows](#windows) - - [Build from source](#build-from-source) - - [Using Docker](#using-docker) - - [Using Haskell stack](#using-haskell-stack) -- [Usage](#usage) - - [Running the chat client](#running-the-chat-client) - - [How to use SimpleX chat](#how-to-use-simplex-chat) - - [Groups](#groups) - - [Sending files](#sending-files) - - [User contact addresses](#user-contact-addresses) - - [Access chat history](#access-chat-history) -- [Roadmap](#Roadmap) -- [License](#license) +## SimpleX Platform design -## Disclaimer +SimpleX is a client-server network with a unique network topology that uses redundant, disposable message relay nodes to asynchronously pass messages via unidirectional (simplex) message queues, providing recipient and sender anonymity. -SimpleX Chat implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks. +Unlike P2P networks, all messages are passed through one or several server nodes, that do not even need to have persistence. In fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records. SimpleX provides better metadata protection than P2P designs, as no global participant identifiers are used to deliver messages, and avoids [the problems of P2P networks](./docs/SIMPLEX.md#comparison-with-p2p-messaging-protocols). -[SimpleXMQ security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) had many improvements in v1.0.0; the implementation has not been audited yet. +Unlike federated networks, the server nodes **do not have records of the users**, **do not communicate with each other** and **do not store messages** after they are delivered to the recipients. There is no way to discover the full list of servers participating in SimpleX network. This design avoids the problem of metadata visibility that all federated networks have and better protects from the network-wide attacks. -We use SimpleX Chat all the time, but you may find some bugs. We would really appreciate if you use it and let us know anything that needs to be fixed or improved. +Only the client devices have information about users, their contacts and groups. -## Network topology - -SimpleX is a client-server network that uses redundant, disposable nodes to asynchronously pass messages via message queues, providing receiver and sender anonymity. - -Unlike P2P networks, all messages are passed through one or several (for redundancy) servers, that do not even need to have persistence (in fact, the current [SMP server implementation](https://github.com/simplex-chat/simplexmq#smp-server) uses in-memory message storage, persisting only the queue records) - it provides better metadata protection than P2P designs, as no global participant ID is required, and avoids many [problems of P2P networks](https://github.com/simplex-chat/simplex-chat/blob/master/simplex.md#comparison-with-p2p-messaging-protocols). - -Unlike federated networks, the participating server nodes **do not have records of the users**, **do not communicate with each other**, **do not store messages** after they are delivered to the recipients, and there is no way to discover the full list of participating servers. SimpleX network avoids the problem of metadata visibility that federated networks have and better protects the network, as servers do not communicate with each other. Each server node provides unidirectional "dumb pipes" to the users, that do authorization without authentication, having no knowledge of the the users or their contacts. Each queue is assigned two Ed448 keys - one for receiver and one for sender - and each queue access is authorized with a signature created using a respective key's private counterpart. - -The routing of messages relies on the knowledge of client devices how user contacts and groups map at any given moment of time to these disposable queues on server nodes. - -## Terminal chat features - -- 1-to-1 chat with multiple people in the same terminal window. -- Group messaging. -- Sending files to contacts and groups. -- User contact addresses - establish connections via multiple-use contact links. -- Messages persisted in a local SQLite database. -- Auto-populated recipient name - just type your messages to reply to the sender once the connection is established. -- Demo SMP servers available and pre-configured in the app - or you can [deploy your own server](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent). -- No global identity or any names visible to the server(s), ensuring full privacy of your contacts and conversations. -- Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)). -- Message integrity validation (via including the digests of the previous messages). -- Authentication of each command/message by SMP servers with automatically generated Ed448 keys. -- TLS 1.3 transport encryption. -- Additional encryption of messages from SMP server to recipient to reduce traffic correlation. - -Public keys involved in key exchange are not used as identity, they are randomly generated for each contact. - -See [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used) for technical details. - - - -## πŸš€ Installation - -### Download chat client - -#### Linux and MacOS - -To **install** or **update** `simplex-chat`, you should run the install script. To do that, use the following cURL or Wget command: - -```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash -``` - -```sh -wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash -``` - -Once the chat client downloads, you can run it with `simplex-chat` command in your terminal. - -Alternatively, you can manually download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below. - -```sh -chmod +x -mv ~/.local/bin/simplex-chat -``` - -(or any other preferred location on `PATH`). - -On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491). - -#### Windows - -```sh -move %APPDATA%/local/bin/simplex-chat.exe -``` - -### Build from source - -> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable). - -#### Using Docker - -On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs): - -```shell -$ git clone git@github.com:simplex-chat/simplex-chat.git -$ cd simplex-chat -$ git checkout stable -$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . -``` - -> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)). - -#### Using Haskell stack - -Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/): - -```shell -curl -sSL https://get.haskellstack.org/ | sh -``` - -and build the project: - -```shell -$ git clone git@github.com:simplex-chat/simplex-chat.git -$ cd simplex-chat -$ git checkout stable -$ stack install -``` - -## Usage - -### Running the chat client - -To start the chat client, run `simplex-chat` from the terminal. - -By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex_v1_chat.db` and `simplex_v1_agent.db` are initialized in it. - -To specify a different file path prefix for the database files use `-d` command line option: - -```shell -$ simplex-chat -d alice -``` - -Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory. - -Three default SMP servers are hosted on Linode - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L42). - -If you deployed your own SMP server(s) you can configure client via `-s` option: - -```shell -$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com -``` - -Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake. - -You can still talk to people using default or any other server - it only affects the location of the message queue when you initiate the connection (and the reply queue can be on another server, as set by the other party's client). - -Run `simplex-chat -h` to see all available options. - -### How to use SimpleX chat - -Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. If some of your contacts chose the same display name, the chat client adds a numeric suffix to their local display name. - -The diagram below shows how to connect and message a contact: - -
- -
- -Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel. - -You are able to create multiple invitations by entering `/connect` multiple times and sending these invitations to the corresponding contacts you'd like to connect with. - -The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established. See agent protocol for explanation of [invitation format](https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md#connection-request). - -The contact who received the invitation should enter `/c ` to accept the connection. This establishes the connection, and both parties are notified. - -They would then use `@ ` commands to send messages. You may also just start typing a message to send it to the contact that was the last. - -Use `/help` in chat to see the list of available commands. - -### Groups - -To create a group use `/g `, then add contacts to it with `/a `. You can then send messages to the group by entering `# `. Use `/help groups` for other commands. - -![simplex-chat](./images/groups.gif) - -> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent. - -### Sending files - -You can send a file to your contact with `/f @ ` - the recipient will have to accept it before it is sent. Use `/help files` for other commands. - -![simplex-chat](./images/files.gif) - -You can send files to a group with `/f # `. - -### User contact addresses - -As an alternative to one-time invitation links, you can create a long-term address with `/ad` (for `/address`). The created address can then be shared via any channel, and used by other users as a link to make a contact request with `/c `. - -You can accept or reject incoming requests with `/ac ` and `/rc ` commands. - -User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you). - -Use `/help address` for other commands. - -![simplex-chat](./images/user-addresses.gif) - -### Access chat history - -SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user. - -You can view and search your chat history by querying your database. Run the below script to create message views in your database. - -```sh -curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -Open SQLite Command Line Shell: - -```sh -sqlite3 ~/.simplex/simplex_v1_chat.db -``` - -See [Message queries](./message_queries.md) for examples. - -> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state. - -**Convenience queries** - -Get all messages from today (`chat_dt` is in UTC): - -```sql -select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt; -``` - -Get overnight messages in the morning: - -```sql -select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt; -``` +See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. ## Roadmap -1. Mobile and desktop apps (in progress). -2. SMP protocol improvements: - - SMP queue redundancy and rotation. - - Message delivery confirmation. - - Support multiple devices. -3. 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. -4. Media server to optimize sending large files to groups. -5. Channels server for large groups and broadcast channels. +- βœ… Easy to deploy SimpleX server with in-memory message storage, without any dependencies. +- βœ… Terminal (console) client with groups and files support. +- βœ… One-click SimpleX server deployment on Linode. +- βœ… End-to-end encryption using double-ratchet protocol with additional encryption layer. +- βœ… Mobile apps v1 for Android and iOS. +- βœ… Private instant notifications for Android using background service. +- βœ… Haskell chat bot templates +- πŸ— Privacy preserving instant notifications for iOS using Apple Push Notification service (in progress). +- πŸ— Mobile app v2 - supporting files, images and groups etc. (in progress). +- πŸ— Chat server and TypeScript client SDK to develop chat interfaces, integrations and chat bots (in progress). +- Chat database portability and encryption. +- End-to-end encrypted audio and video calls via the mobile apps. +- Web widgets for custom interactivity in the chats. +- SMP protocol improvements: + - SMP queue redundancy and rotation. + - Message delivery confirmation. + - 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. +- Media server to optimize sending large files to groups. +- Channels server for large groups and broadcast channels. + +## Disclaimer + +[SimpleX protocols and security model](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) was reviewed and had many improvements in v1.0.0; we are currently arranging for the independent implementation audit. + +You are likely to discover some bugs - we would really appreciate if you use it and let us know anything that needs to be fixed or improved. ## License diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 31ec58682c..6d7cdb93b1 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -96,6 +96,9 @@ dependencies { //Camera Permission implementation "com.google.accompanist:accompanist-permissions:0.23.0" + // Link Previews + implementation 'org.jsoup:jsoup:1.13.1' + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 2435f012bd..1b08da4697 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -25,9 +25,9 @@ import chat.simplex.app.views.WelcomeView import chat.simplex.app.views.chat.ChatView import chat.simplex.app.views.chatlist.ChatListView import chat.simplex.app.views.chatlist.openChat -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi -import chat.simplex.app.views.newchat.* +import chat.simplex.app.views.helpers.* +import chat.simplex.app.views.newchat.connectViaUri +import chat.simplex.app.views.newchat.withUriAction import java.util.concurrent.TimeUnit //import kotlinx.serialization.decodeFromString @@ -122,10 +122,18 @@ fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) { chatModel.appOpenUrl.value = uri } else { withUriAction(uri) { action -> + val title = when (action) { + "contact" -> generalGetString(R.string.connect_via_contact_link) + "invitation" -> generalGetString(R.string.connect_via_invitation_link) + else -> { + Log.e(TAG, "URI has unexpected action. Alert shown.") + action + } + } AlertManager.shared.showAlertMsg( - title = "Connect via $action link?", - text = "Your profile will be sent to the contact that you received this link from.", - confirmText = "Connect", + title = title, + text = generalGetString(R.string.profile_will_be_sent_to_contact_sending_link), + confirmText = generalGetString(R.string.connect_via_link_verb), onConfirm = { withApi { Log.d(TAG, "connectIfOpenedViaUri: connecting") diff --git a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt index 5506c72daf..73397f1908 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/SimplexApp.kt @@ -41,6 +41,7 @@ class SimplexApp: Application(), LifecycleEventObserver { override fun onCreate() { super.onCreate() + context = this ProcessLifecycleOwner.get().lifecycle.addObserver(this) withApi { val user = chatController.apiGetActiveUser() @@ -65,9 +66,10 @@ class SimplexApp: Application(), LifecycleEventObserver { } companion object { + lateinit var context: SimplexApp private set + init { val socketName = "local.socket.address.listen.native.cmd2" - val s = Semaphore(0) thread(name="stdout/stderr pipe") { Log.d(TAG, "starting server") @@ -82,7 +84,7 @@ class SimplexApp: Application(), LifecycleEventObserver { val inStreamReader = InputStreamReader(inStream) val input = BufferedReader(inStreamReader) - while(true) { + while (true) { val line = input.readLine() ?: break Log.w("$TAG (stdout/stderr)", line) logbuffer.add(line) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index cbf3880903..8a4dbe9235 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -7,8 +7,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.* import androidx.compose.ui.text.style.TextDecoration +import chat.simplex.app.R import chat.simplex.app.ui.theme.SecretColor import chat.simplex.app.ui.theme.SimplexBlue +import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.* import kotlinx.serialization.* import kotlinx.serialization.descriptors.* @@ -267,12 +269,12 @@ data class Chat ( @Serializable sealed class NetworkStatus { - val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…" + val statusString: String get() = if (this is Connected) generalGetString(R.string.server_connected) else generalGetString(R.string.server_connecting) val statusExplanation: String get() = - when { - this is Connected -> "You are connected to the server used to receive messages from this contact." - this is Error -> "Trying to connect to the server used to receive messages from this contact (error: $error)." - else -> "Trying to connect to the server used to receive messages from this contact." + when (this) { + is Connected -> generalGetString(R.string.connected_to_server_to_receive_messages_from_contact) + is Error -> String.format(generalGetString(R.string.trying_to_connect_to_server_to_receive_messages_with_error), error) + else -> generalGetString(R.string.trying_to_connect_to_server_to_receive_messages) } @Serializable @SerialName("unknown") class Unknown: NetworkStatus() @@ -461,6 +463,23 @@ class GroupMember ( } } +@Serializable +class LinkPreview ( + val uri: String, + val title: String, + val description: String, + val image: String +) { + companion object { + val sampleData = LinkPreview( + uri = "https://www.duckduckgo.com", + title = "Privacy, simplified.", + description = "The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.", + image = "data:image/jpg;base64,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" + ) + } +} + @Serializable class MemberSubError ( val member: GroupMember, @@ -551,7 +570,7 @@ data class ChatItem ( id: Long = 1, dir: CIDirection = CIDirection.DirectRcv(), ts: Instant = Clock.System.now(), - text: String = "this item is deleted", + text: String = "this item is deleted", // sample not localized status: CIStatus = CIStatus.RcvRead() ) = ChatItem( @@ -663,35 +682,40 @@ interface ItemContent { @Serializable sealed class CIContent: ItemContent { abstract override val text: String + abstract val msgContent: MsgContent? @Serializable @SerialName("sndMsgContent") - class SndMsgContent(val msgContent: MsgContent): CIContent() { + class SndMsgContent(override val msgContent: MsgContent): CIContent() { override val text get() = msgContent.text } @Serializable @SerialName("rcvMsgContent") - class RcvMsgContent(val msgContent: MsgContent): CIContent() { + class RcvMsgContent(override val msgContent: MsgContent): CIContent() { override val text get() = msgContent.text } @Serializable @SerialName("sndDeleted") class SndDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = "deleted" + override val text get() = generalGetString(R.string.deleted_description) + override val msgContent get() = null } @Serializable @SerialName("rcvDeleted") class RcvDeleted(val deleteMode: CIDeleteMode): CIContent() { - override val text get() = "deleted" + override val text get() = generalGetString(R.string.deleted_description) + override val msgContent get() = null } @Serializable @SerialName("sndFileInvitation") class SndFileInvitation(val fileId: Long, val filePath: String): CIContent() { - override val text get() = "sending files is not supported yet" + override val text get() = generalGetString(R.string.sending_files_not_yet_supported) + override val msgContent get() = null } @Serializable @SerialName("rcvFileInvitation") class RcvFileInvitation(val rcvFileTransfer: RcvFileTransfer): CIContent() { - override val text get() = "receiving files is not supported yet" + override val text get() = generalGetString(R.string.receiving_files_not_yet_supported) + override val msgContent get() = null } } @@ -707,7 +731,7 @@ class CIQuote ( override val text: String get() = content.text fun sender(user: User): String? = when (chatDir) { - is CIDirection.DirectSnd -> "you" + is CIDirection.DirectSnd -> generalGetString(R.string.sender_you_pronoun) is CIDirection.DirectRcv -> null is CIDirection.GroupSnd -> user.displayName is CIDirection.GroupRcv -> chatDir.groupMember.memberProfile.displayName @@ -720,15 +744,23 @@ class CIQuote ( } } +@Suppress("SERIALIZER_TYPE_INCOMPATIBLE") @Serializable(with = MsgContentSerializer::class) sealed class MsgContent { abstract val text: String + @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() + + @Serializable(with = MsgContentSerializer::class) + class MCLink(override val text: String, val preview: LinkPreview): MsgContent() + + @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() val cmdString: String get() = when (this) { is MCText -> "text $text" + is MCLink -> "json ${json.encodeToString(this)}" is MCUnknown -> "json $json" } } @@ -739,6 +771,10 @@ object MsgContentSerializer : KSerializer { element("MCText", buildClassSerialDescriptor("MCText") { element("text") }) + element("MCLink", buildClassSerialDescriptor("MCLink") { + element("text") + element("preview") + }) element("MCUnknown", buildClassSerialDescriptor("MCUnknown")) } @@ -748,16 +784,20 @@ object MsgContentSerializer : KSerializer { return if (json is JsonObject) { if ("type" in json) { val t = json["type"]?.jsonPrimitive?.content ?: "" - val text = json["text"]?.jsonPrimitive?.content ?: "unknown message format" + val text = json["text"]?.jsonPrimitive?.content ?: generalGetString(R.string.unknown_message_format) when (t) { "text" -> MsgContent.MCText(text) + "link" -> { + val preview = Json.decodeFromString(json["preview"].toString()) + MsgContent.MCLink(text, preview) + } else -> MsgContent.MCUnknown(t, text, json) } } else { - MsgContent.MCUnknown(text = "invalid message format", json = json) + MsgContent.MCUnknown(text = generalGetString(R.string.invalid_message_format), json = json) } } else { - MsgContent.MCUnknown(text = "invalid message format", json = json) + MsgContent.MCUnknown(text = generalGetString(R.string.invalid_message_format), json = json) } } @@ -769,6 +809,12 @@ object MsgContentSerializer : KSerializer { put("type", "text") put("text", value.text) } + is MsgContent.MCLink -> + buildJsonObject { + put("type", "link") + put("text", value.text) + put("preview", json.encodeToJsonElement(value.preview)) + } is MsgContent.MCUnknown -> value.json } encoder.encodeJsonElement(json) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 1872dbb72f..0234dfce44 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -15,8 +15,8 @@ import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import chat.simplex.app.* -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.R +import chat.simplex.app.views.helpers.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -80,15 +80,19 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt suspend fun sendCmd(cmd: CC): CR { return withContext(Dispatchers.IO) { val c = cmd.cmdString - chatModel.terminalItems.add(TerminalItem.cmd(cmd)) + if (cmd !is CC.ApiParseMarkdown) { + chatModel.terminalItems.add(TerminalItem.cmd(cmd)) + Log.d(TAG, "sendCmd: ${cmd.cmdType}") + } val json = chatSendCmd(ctrl, c) - Log.d(TAG, "sendCmd: ${cmd.cmdType}") val r = APIResponse.decodeStr(json) Log.d(TAG, "sendCmd response type ${r.resp.responseType}") if (r.resp is CR.Response || r.resp is CR.Invalid) { Log.d(TAG, "sendCmd response json $json") } - chatModel.terminalItems.add(TerminalItem.resp(r.resp)) + if (r.resp !is CR.ParsedMarkdown) { + chatModel.terminalItems.add(TerminalItem.resp(r.resp)) + } r.resp } } @@ -179,8 +183,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt else -> { Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}") AlertManager.shared.showAlertMsg( - "Error saving SMP servers", - "Make sure SMP server addresses are in correct format, line separated and are not duplicated." + generalGetString(R.string.error_saving_smp_servers), + generalGetString(R.string.ensure_smp_server_address_are_correct_format_and_unique) ) false } @@ -199,15 +203,17 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt when { r is CR.SentConfirmation || r is CR.SentInvitation -> return true r is CR.ContactAlreadyExists -> { - AlertManager.shared.showAlertMsg("Contact already exists", - "You are already connected to ${r.contact.displayName} via this link." + AlertManager.shared.showAlertMsg( + generalGetString(R.string.contact_already_exists), + String.format(generalGetString(R.string.you_are_already_connected_to_vName_via_this_link), r.contact.displayName) ) return false } r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat && r.chatError.errorType is ChatErrorType.InvalidConnReq -> { - AlertManager.shared.showAlertMsg("Invalid connection link", - "Please check that you used the correct link or ask your contact to send you another one." + AlertManager.shared.showAlertMsg( + generalGetString(R.string.invalid_connection_link), + generalGetString(R.string.please_check_correct_link_and_maybe_ask_for_a_new_one) ) return false } @@ -226,8 +232,8 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt val e = r.chatError if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) { AlertManager.shared.showAlertMsg( - "Can't delete contact!", - "Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}." + generalGetString(R.string.cannot_delete_contact), + String.format(generalGetString(R.string.contact_cannot_be_deleted_as_they_are_in_groups), e.errorType.contact.displayName, e.errorType.groupNames) ) } } @@ -244,6 +250,13 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt return null } + suspend fun apiParseMarkdown(text: String): List? { + val r = sendCmd(CC.ApiParseMarkdown(text)) + if (r is CR.ParsedMarkdown) return r.formattedText + Log.e(TAG, "apiParseMarkdown bad response: ${r.responseType} ${r.details}") + return null + } + suspend fun apiCreateUserAddress(): String? { val r = sendCmd(CC.CreateMyAddress()) if (r is CR.UserContactLinkCreated) return r.connReqContact @@ -400,35 +413,22 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt Row { Icon( Icons.Outlined.Bolt, - contentDescription = "Instant notifications", + contentDescription = generalGetString(R.string.icon_descr_instant_notifications), ) - Text("Private instant notifications!", fontWeight = FontWeight.Bold) + Text(generalGetString(R.string.private_instant_notifications), fontWeight = FontWeight.Bold) } }, text = { Column { Text( - buildAnnotatedString { - append("To preserve your privacy, instead of push notifications the app has a ") - withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { - append("SimpleX background service") - } - append(" – it uses a few percent of the battery per day.") - }, + annotatedStringResource(R.string.to_preserve_privacy_simplex_has_background_service_instead_of_push_notifications_it_uses_a_few_pc_battery), Modifier.padding(bottom = 8.dp) ) - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontWeight = FontWeight.Medium)) { - append("It can be disabled via settings") - } - append(" – notifications will still be shown while the app is running.") - } - ) + Text(annotatedStringResource(R.string.it_can_disabled_via_settings_notifications_still_shown)) } }, confirmButton = { - Button(onClick = AlertManager.shared::hideAlert) { Text("Ok") } + Button(onClick = AlertManager.shared::hideAlert) { Text(generalGetString(R.string.ok)) } } ) } @@ -483,6 +483,7 @@ sealed class CC { class Connect(val connReq: String): CC() class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiUpdateProfile(val profile: Profile): CC() + class ApiParseMarkdown(val text: String): CC() class CreateMyAddress: CC() class DeleteMyAddress: CC() class ShowMyAddress: CC() @@ -507,6 +508,7 @@ sealed class CC { is Connect -> "/connect $connReq" is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiUpdateProfile -> "/_profile ${json.encodeToString(profile)}" + is ApiParseMarkdown -> "/_parse $text" is CreateMyAddress -> "/address" is DeleteMyAddress -> "/delete_address" is ShowMyAddress -> "/show_address" @@ -532,6 +534,7 @@ sealed class CC { is Connect -> "connect" is ApiDeleteChat -> "apiDeleteChat" is ApiUpdateProfile -> "updateProfile" + is ApiParseMarkdown -> "apiParseMarkdown" is CreateMyAddress -> "createMyAddress" is DeleteMyAddress -> "deleteMyAddress" is ShowMyAddress -> "showMyAddress" @@ -592,6 +595,7 @@ sealed class CR { @Serializable @SerialName("contactDeleted") class ContactDeleted(val contact: Contact): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange: CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val fromProfile: Profile, val toProfile: Profile): CR() + @Serializable @SerialName("apiParsedMarkdown") class ParsedMarkdown(val formattedText: List? = null): CR() @Serializable @SerialName("userContactLink") class UserContactLink(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkCreated") class UserContactLinkCreated(val connReqContact: String): CR() @Serializable @SerialName("userContactLinkDeleted") class UserContactLinkDeleted: CR() @@ -632,6 +636,7 @@ sealed class CR { is ContactDeleted -> "contactDeleted" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" + is ParsedMarkdown -> "apiParsedMarkdown" is UserContactLink -> "userContactLink" is UserContactLinkCreated -> "userContactLinkCreated" is UserContactLinkDeleted -> "userContactLinkDeleted" @@ -673,6 +678,7 @@ sealed class CR { is ContactDeleted -> json.encodeToString(contact) is UserProfileNoChange -> noDetails() is UserProfileUpdated -> json.encodeToString(toProfile) + is ParsedMarkdown -> json.encodeToString(formattedText) is UserContactLink -> connReqContact is UserContactLinkCreated -> connReqContact is UserContactLinkDeleted -> noDetails() @@ -700,7 +706,7 @@ sealed class CR { is Invalid -> str } - fun noDetails(): String ="${responseType}: no details" + fun noDetails(): String ="${responseType}: " + generalGetString(R.string.no_details) } abstract class TerminalItem { diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt index 3f4b1c6abf..7aeecca387 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Color.kt @@ -7,7 +7,7 @@ val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) val Gray = Color(0x22222222) -val SimplexBlue = Color(0, 136, 255, 255) +val SimplexBlue = Color(0, 136, 255, 255) // If this value changes also need to update #0088ff in string resource files val SimplexGreen = Color(98, 196, 103, 255) val SecretColor = Color(0x40808080) val LightGray = Color(241, 242, 246, 255) diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt index 3719e860a6..8b1ccfeded 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Theme.kt @@ -5,7 +5,7 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable private val DarkColorPalette = darkColors( - primary = SimplexBlue, + primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primaryVariant = SimplexGreen, secondary = DarkGray, // background = Color.Black, @@ -20,7 +20,7 @@ private val DarkColorPalette = darkColors( // onError: Color = Color.Black, ) private val LightColorPalette = lightColors( - primary = SimplexBlue, + primary = SimplexBlue, // If this value changes also need to update #0088ff in string resource files primaryVariant = SimplexGreen, secondary = LightGray, // background = Color.White, diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt index 98b828c42e..2042ef469e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/TerminalView.kt @@ -19,9 +19,7 @@ import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.SendMsgView -import chat.simplex.app.views.helpers.CloseSheetBar -import chat.simplex.app.views.helpers.withApi -import chat.simplex.app.views.newchat.ModalManager +import chat.simplex.app.views.helpers.* import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.launch @@ -43,7 +41,15 @@ fun TerminalLayout(terminalItems: List, close: () -> Unit, sendCom ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { CloseSheetBar(close) }, - bottomBar = { SendMsgView(msg = remember { mutableStateOf("") }, sendCommand) }, + bottomBar = { + SendMsgView( + msg = remember { mutableStateOf("") }, + linkPreview = remember { mutableStateOf(null) }, + cancelledLinks = remember { mutableSetOf() }, + parseMarkdown = { null }, + sendMessage = sendCommand + ) + }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Surface( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt index 996b0bd2e1..675655bb9d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/WelcomeView.kt @@ -17,6 +17,7 @@ import chat.simplex.app.R import chat.simplex.app.SimplexService import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.withApi import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding @@ -39,22 +40,22 @@ fun WelcomeView(chatModel: ChatModel) { ) { Image( painter = painterResource(R.drawable.logo), - contentDescription = "Simplex Logo", + contentDescription = generalGetString(R.string.image_descr_simplex_logo), modifier = Modifier.padding(vertical = 15.dp) ) Text( - "You control your chat!", + generalGetString(R.string.you_control_your_chat), style = MaterialTheme.typography.h4, color = MaterialTheme.colors.onBackground ) Text( - "The messaging and application platform protecting your privacy and security.", + generalGetString(R.string.the_messaging_and_app_platform_protecting_your_privacy_and_security), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) Spacer(Modifier.height(8.dp)) Text( - "We don't store any of your contacts or messages (once delivered) on the servers.", + generalGetString(R.string.we_do_not_store_contacts_or_messages_on_servers), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) @@ -79,19 +80,19 @@ fun CreateProfilePanel(chatModel: ChatModel) { modifier=Modifier.fillMaxSize() ) { Text( - "Create profile", + generalGetString(R.string.create_profile), style = MaterialTheme.typography.h4, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(vertical = 5.dp) ) Text( - "Your profile is stored on your device and shared only with your contacts.", + generalGetString(R.string.your_profile_is_stored_on_your_decide_and_shared_only_with_your_contacts), style = MaterialTheme.typography.body1, color = MaterialTheme.colors.onBackground ) Spacer(Modifier.height(10.dp)) Text( - "Display Name", + generalGetString(R.string.display_name), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 3.dp) @@ -113,7 +114,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { ), singleLine = true ) - val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else "" + val errorText = if(!isValidDisplayName(displayName)) generalGetString(R.string.display_name_cannot_contain_whitespace) else "" Text( errorText, @@ -123,7 +124,7 @@ fun CreateProfilePanel(chatModel: ChatModel) { Spacer(Modifier.height(3.dp)) Text( - "Full Name (Optional)", + generalGetString(R.string.full_name_optional__prompt), style = MaterialTheme.typography.h6, color = MaterialTheme.colors.onBackground, modifier = Modifier.padding(bottom = 5.dp) @@ -157,6 +158,6 @@ fun CreateProfilePanel(chatModel: ChatModel) { } }, enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName)) - ) { Text("Create") } + ) { Text(generalGetString(R.string.create_profile_button)) } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt index ed43311f96..d37c752cbe 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatInfoView.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.helpers.* @@ -29,9 +30,9 @@ fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) { close = close, deleteContact = { AlertManager.shared.showAlertMsg( - title = "Delete contact?", - text = "Contact and all messages will be deleted - this cannot be undone!", - confirmText = "Delete", + title = generalGetString(R.string.delete_contact__question), + text = generalGetString(R.string.delete_contact_all_messages_deleted_cannot_undo_warning), + confirmText = generalGetString(R.string.delete_verb), onConfirm = { val cInfo = chat.chatInfo withApi { @@ -96,7 +97,8 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) { Box(Modifier.padding(48.dp)) { SimpleButton( - "Delete contact", icon = Icons.Outlined.Delete, + generalGetString(R.string.button_delete_contact), + icon = Icons.Outlined.Delete, color = Color.Red, click = deleteContact ) @@ -110,13 +112,13 @@ fun ServerImage(chat: Chat) { val status = chat.serverInfo.networkStatus when { status is Chat.NetworkStatus.Connected -> - Icon(Icons.Filled.Circle, "Connected", tint = MaterialTheme.colors.primaryVariant) + Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_server_status_connected), tint = MaterialTheme.colors.primaryVariant) status is Chat.NetworkStatus.Disconnected -> - Icon(Icons.Filled.Pending, "Disconnected", tint = HighOrLowlight) + Icon(Icons.Filled.Pending, generalGetString(R.string.icon_descr_server_status_disconnected), tint = HighOrLowlight) status is Chat.NetworkStatus.Error -> - Icon(Icons.Filled.Error, "Error", tint = HighOrLowlight) + Icon(Icons.Filled.Error, generalGetString(R.string.icon_descr_server_status_error), tint = HighOrLowlight) else -> - Icon(Icons.Outlined.Circle, "Pending", tint = HighOrLowlight) + Icon(Icons.Outlined.Circle, generalGetString(R.string.icon_descr_server_status_pending), tint = HighOrLowlight) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index ef7aea17d7..3ffab8066e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -22,17 +22,16 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.TAG import chat.simplex.app.model.* import chat.simplex.app.ui.theme.* import chat.simplex.app.views.chat.item.ChatItemView import chat.simplex.app.views.chatlist.openChat import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.ModalManager import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.datetime.Clock @Composable @@ -44,7 +43,9 @@ fun ChatView(chatModel: ChatModel) { } else { val quotedItem = remember { mutableStateOf(null) } val editingItem = remember { mutableStateOf(null) } + val linkPreview = remember { mutableStateOf(null) } var msg = remember { mutableStateOf("") } + BackHandler { chatModel.chatId.value = null } // TODO a more advanced version would mark as read only if in view LaunchedEffect(chat.chatItems) { @@ -62,7 +63,7 @@ fun ChatView(chatModel: ChatModel) { } } } - ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, + ChatLayout(user, chat, chatModel.chatItems, msg, quotedItem, editingItem, linkPreview, back = { chatModel.chatId.value = null }, info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } }, openDirectChat = { contactId -> @@ -85,17 +86,19 @@ fun ChatView(chatModel: ChatModel) { ) if (updatedItem != null) chatModel.upsertChatItem(cInfo, updatedItem.chatItem) } else { + val linkPreviewData = linkPreview.value val newItem = chatModel.controller.apiSendMessage( type = cInfo.chatType, id = cInfo.apiId, quotedItemId = quotedItem.value?.meta?.itemId, - mc = MsgContent.MCText(msg) + mc = if (linkPreviewData != null) MsgContent.MCLink(msg, linkPreviewData) else MsgContent.MCText(msg) ) if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem) } // hide "in progress" editingItem.value = null quotedItem.value = null + linkPreview.value = null } }, resetMessage = { msg.value = "" }, @@ -110,7 +113,8 @@ fun ChatView(chatModel: ChatModel) { ) if (toItem != null) chatModel.removeChatItem(cInfo, toItem.chatItem) } - } + }, + parseMarkdown = { text -> runBlocking { chatModel.controller.apiParseMarkdown(text) } } ) } } @@ -123,12 +127,14 @@ fun ChatLayout( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, + linkPreview: MutableState, back: () -> Unit, info: () -> Unit, openDirectChat: (Long) -> Unit, sendMessage: (String) -> Unit, resetMessage: () -> Unit, - deleteMessage: (Long, CIDeleteMode) -> Unit + deleteMessage: (Long, CIDeleteMode) -> Unit, + parseMarkdown: (String) -> List? ) { Surface( Modifier @@ -138,7 +144,7 @@ fun ChatLayout( ProvideWindowInsets(windowInsetsAnimationsEnabled = true) { Scaffold( topBar = { ChatInfoToolbar(chat, back, info) }, - bottomBar = { ComposeView(msg, quotedItem, editingItem, sendMessage, resetMessage) }, + bottomBar = { ComposeView(msg, quotedItem, editingItem, linkPreview, sendMessage, resetMessage, parseMarkdown) }, modifier = Modifier.navigationBarsWithImePadding() ) { contentPadding -> Box(Modifier.padding(contentPadding)) { @@ -163,7 +169,7 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) { IconButton(onClick = back) { Icon( Icons.Outlined.ArrowBackIos, - "Back", + generalGetString(R.string.back), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) @@ -335,12 +341,14 @@ fun PreviewChatLayout() { msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, + linkPreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + parseMarkdown = { null } ) } } @@ -378,12 +386,14 @@ fun PreviewGroupChatLayout() { msg = remember { mutableStateOf("") }, quotedItem = remember { mutableStateOf(null) }, editingItem = remember { mutableStateOf(null) }, + linkPreview = remember { mutableStateOf(null) }, back = {}, info = {}, openDirectChat = {}, sendMessage = {}, resetMessage = {}, - deleteMessage = { _, _ -> } + deleteMessage = { _, _ -> }, + parseMarkdown = { null } ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 3cea4b1f52..79e4b71ad4 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -1,9 +1,9 @@ package chat.simplex.app.views.chat import androidx.compose.foundation.layout.Column -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import chat.simplex.app.model.ChatItem +import androidx.compose.runtime.* +import chat.simplex.app.model.* +import chat.simplex.app.views.helpers.ComposeLinkView // TODO ComposeState @@ -12,10 +12,24 @@ fun ComposeView( msg: MutableState, quotedItem: MutableState, editingItem: MutableState, + linkPreview: MutableState, sendMessage: (String) -> Unit, - resetMessage: () -> Unit + resetMessage: () -> Unit, + parseMarkdown: (String) -> List? ) { + val cancelledLinks = remember { mutableSetOf() } + + fun cancelPreview() { + val uri = linkPreview.value?.uri + if (uri != null) { + cancelledLinks.add(uri) + } + linkPreview.value = null + } + Column { + val lp = linkPreview.value + if (lp != null) ComposeLinkView(lp, ::cancelPreview) when { quotedItem.value != null -> { ContextItemView(quotedItem) @@ -25,6 +39,6 @@ fun ComposeView( } else -> {} } - SendMsgView(msg, sendMessage, editing = editingItem.value != null) + SendMsgView(msg, linkPreview, cancelledLinks, parseMarkdown, sendMessage, editing = editingItem.value != null) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt index 3690e79883..8a16d670ec 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContextItemView.kt @@ -12,10 +12,12 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.CIDirection import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable @@ -50,7 +52,7 @@ fun ContextItemView( }) { Icon( Icons.Outlined.Close, - contentDescription = "Cancel", + contentDescription = generalGetString(R.string.cancel_verb), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 89b5851f89..7fcda1bd03 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -20,22 +20,82 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.* +import chat.simplex.app.views.helpers.* +import kotlinx.coroutines.delay @Composable -fun SendMsgView(msg: MutableState, sendMessage: (String) -> Unit, editing: Boolean = false) { +fun SendMsgView( + msg: MutableState, + linkPreview: MutableState, + cancelledLinks: MutableSet, + parseMarkdown: (String) -> List?, + sendMessage: (String) -> Unit, + editing: Boolean = false +) { val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground) var textStyle by remember { mutableStateOf(smallFont) } + val linkUrl = remember { mutableStateOf(null) } + val prevLinkUrl = remember { mutableStateOf(null) } + val pendingLinkUrl = remember { mutableStateOf(null) } + + fun isSimplexLink(link: String): Boolean = + link.startsWith("https://simplex.chat",true) || link.startsWith("http://simplex.chat", true) + + fun parseMessage(msg: String): String? { + val parsedMsg = parseMarkdown(msg) + val link = parsedMsg?.firstOrNull { ft -> ft.format is Format.Uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) } + return link?.text + } + + fun loadLinkPreview(url: String, wait: Long? = null) { + if (pendingLinkUrl.value == url) { + withApi { + if (wait != null) delay(wait) + val lp = getLinkPreview(url) + if (pendingLinkUrl.value == url) { + linkPreview.value = lp + pendingLinkUrl.value = null + } + } + } + } + + fun showLinkPreview(s: String) { + prevLinkUrl.value = linkUrl.value + linkUrl.value = parseMessage(s) + val url = linkUrl.value + if (url != null) { + if (url != linkPreview.value?.uri && url != pendingLinkUrl.value) { + pendingLinkUrl.value = url + loadLinkPreview(url, wait = if (prevLinkUrl.value == url) null else 1500L) + } + } else { + linkPreview.value = null + } + } + + fun resetLinkPreview() { + linkUrl.value = null + prevLinkUrl.value = null + pendingLinkUrl.value = null + cancelledLinks.clear() + } + BasicTextField( value = msg.value, - onValueChange = { - msg.value = it - textStyle = if (isShortEmoji(it)) { - if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont + onValueChange = { s -> + msg.value = s + if (isShortEmoji(s)) { + textStyle = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont } else { - smallFont + textStyle = smallFont + if (s.isNotEmpty()) showLinkPreview(s) + else resetLinkPreview() } }, textStyle = textStyle, @@ -67,7 +127,7 @@ fun SendMsgView(msg: MutableState, sendMessage: (String) -> Unit, editin val color = if (msg.value.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray Icon( if (editing) Icons.Filled.Check else Icons.Outlined.ArrowUpward, - "Send Message", + generalGetString(R.string.icon_descr_send_message), tint = Color.White, modifier = Modifier .size(36.dp) @@ -99,6 +159,9 @@ fun PreviewSendMsgView() { SimpleXTheme { SendMsgView( msg = remember { mutableStateOf("") }, + linkPreview = remember {mutableStateOf(null) }, + cancelledLinks = mutableSetOf(), + parseMarkdown = { null }, sendMessage = { msg -> println(msg) } ) } @@ -115,7 +178,10 @@ fun PreviewSendMsgViewEditing() { SimpleXTheme { SendMsgView( msg = remember { mutableStateOf("") }, + linkPreview = remember {mutableStateOf(null) }, + cancelledLinks = mutableSetOf(), sendMessage = { msg -> println(msg) }, + parseMarkdown = { null }, editing = true ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt index ec00538bce..ca6e5a71c0 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIMetaView.kt @@ -4,43 +4,64 @@ import androidx.compose.foundation.layout.* import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.CIDirection -import chat.simplex.app.model.ChatItem +import chat.simplex.app.R +import chat.simplex.app.model.* import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimplexBlue +import chat.simplex.app.views.helpers.generalGetString import kotlinx.datetime.Clock @Composable fun CIMetaView(chatItem: ChatItem) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Row(verticalAlignment = Alignment.CenterVertically) { if (!chatItem.isDeletedContent) { if (chatItem.meta.itemEdited) { Icon( Icons.Filled.Edit, - modifier = Modifier.height(12.dp), - contentDescription = "Edited", + modifier = Modifier.height(12.dp).padding(end = 1.dp), + contentDescription = generalGetString(R.string.icon_descr_edited), tint = HighOrLowlight, ) } - // TODO status + CIStatusView(chatItem.meta.itemStatus) } Text( chatItem.timestampText, color = HighOrLowlight, - fontSize = 14.sp + fontSize = 14.sp, + modifier = Modifier.padding(start = 3.dp) ) } } + +@Composable +fun CIStatusView(status: CIStatus) { + when (status) { + is CIStatus.SndSent -> { + Icon(Icons.Filled.Check, generalGetString(R.string.icon_descr_sent_msg_status_sent), Modifier.height(12.dp), tint = HighOrLowlight) + } + is CIStatus.SndErrorAuth -> { + Icon(Icons.Filled.Close, generalGetString(R.string.icon_descr_sent_msg_status_unauthorized_send), Modifier.height(12.dp), tint = Color.Red) + } + is CIStatus.SndError -> { + Icon(Icons.Filled.WarningAmber, generalGetString(R.string.icon_descr_sent_msg_status_send_failed), Modifier.height(12.dp), tint = Color.Yellow) + } + is CIStatus.RcvNew -> { + Icon(Icons.Filled.Circle, generalGetString(R.string.icon_descr_received_msg_status_unread), Modifier.height(12.dp), tint = SimplexBlue) + } + else -> {} + } +} + @Preview @Composable fun PreviewCIMetaView() { @@ -51,6 +72,48 @@ fun PreviewCIMetaView() { ) } +@Preview +@Composable +fun PreviewCIMetaViewUnread() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + status = CIStatus.RcvNew() + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewSendFailed() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + status = CIStatus.SndError(AgentErrorType.CMD(CommandErrorType.SYNTAX())) + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewSendNoAuth() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndErrorAuth() + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewSendSent() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", status = CIStatus.SndSent() + ) + ) +} + @Preview @Composable fun PreviewCIMetaViewEdited() { @@ -62,6 +125,30 @@ fun PreviewCIMetaViewEdited() { ) } +@Preview +@Composable +fun PreviewCIMetaViewEditedUnread() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectRcv(), Clock.System.now(), "hello", + itemEdited = true, + status=CIStatus.RcvNew() + ) + ) +} + +@Preview +@Composable +fun PreviewCIMetaViewEditedSent() { + CIMetaView( + chatItem = ChatItem.getSampleData( + 1, CIDirection.DirectSnd(), Clock.System.now(), "hello", + itemEdited = true, + status=CIStatus.SndSent() + ) + ) +} + @Preview @Composable fun PreviewCIMetaViewDeletedContent() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index dfce857f10..f067e6f8df 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -16,8 +16,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.UriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.* -import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @@ -55,21 +55,21 @@ fun ChatItemView( } if (cItem.isMsgContent) { DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - ItemAction("Reply", Icons.Outlined.Reply, onClick = { + ItemAction(generalGetString(R.string.reply_verb), Icons.Outlined.Reply, onClick = { editingItem.value = null quotedItem.value = cItem showMenu = false }) - ItemAction("Share", Icons.Outlined.Share, onClick = { + ItemAction(generalGetString(R.string.share_verb), Icons.Outlined.Share, onClick = { shareText(cxt, cItem.content.text) showMenu = false }) - ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = { + ItemAction(generalGetString(R.string.copy_verb), Icons.Outlined.ContentCopy, onClick = { copyText(cxt, cItem.content.text) showMenu = false }) if (cItem.chatDir.sent && cItem.meta.editable) { - ItemAction("Edit", Icons.Filled.Edit, onClick = { + ItemAction(generalGetString(R.string.edit_verb), Icons.Filled.Edit, onClick = { quotedItem.value = null editingItem.value = cItem msg.value = cItem.content.text @@ -77,7 +77,7 @@ fun ChatItemView( }) } ItemAction( - "Delete", + generalGetString(R.string.delete_verb), Icons.Outlined.Delete, onClick = { showMenu = false @@ -109,8 +109,8 @@ private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit, col fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteMode) -> Unit) { AlertManager.shared.showAlertDialogButtons( - title = "Delete message?", - text = "Message will be deleted - this cannot be undone!", + title = generalGetString(R.string.delete_message__question), + text = generalGetString(R.string.delete_message_cannot_be_undone_warning), buttons = { Row( Modifier @@ -121,13 +121,13 @@ fun deleteMessageAlertDialog(chatItem: ChatItem, deleteMessage: (Long, CIDeleteM Button(onClick = { deleteMessage(chatItem.id, CIDeleteMode.cidmInternal) AlertManager.shared.hideAlert() - }) { Text("For me only") } + }) { Text(generalGetString(R.string.for_me_only)) } // if (chatItem.meta.editable) { // Spacer(Modifier.padding(horizontal = 4.dp)) // Button(onClick = { // deleteMessage(chatItem.id, CIDeleteMode.cidmBroadcast) // AlertManager.shared.hideAlert() -// }) { Text("For everyone") } +// }) { Text(generalGetString(R.string.for_everybody)) } // } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt index 849550e557..5ed2f39f2b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/DeletedItemView.kt @@ -1,9 +1,8 @@ package chat.simplex.app.views.chat.item import android.content.res.Configuration -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.runtime.Composable @@ -11,10 +10,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontStyle -import androidx.compose.ui.tooling.preview.* +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import chat.simplex.app.model.* +import chat.simplex.app.model.ChatItem import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 302f3ee1dc..54860a84f8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.ChatItemLinkView import kotlinx.datetime.Clock val SentColorLight = Color(0x1E45B8FF) @@ -45,8 +46,8 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho ) } } - Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { - if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + if (ci.formattedText == null && isShortEmoji(ci.content.text)) { + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { Column( Modifier .padding(bottom = 2.dp) @@ -56,11 +57,19 @@ fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null, sho EmojiText(ci.content.text) Text("") } - } else { - MarkdownText( - ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, - metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true - ) + } + } else { + Column(Modifier.fillMaxWidth()) { + val mc = ci.content.msgContent + if (mc is MsgContent.MCLink) { + ChatItemLinkView(mc.preview) + } + Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + MarkdownText( + ci.content, ci.formattedText, if (showMember) ci.memberDisplayName else null, + metaText = ci.timestampText, edited = ci.meta.itemEdited, uriHandler = uriHandler, senderBold = true + ) + } } } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt index 8d25881786..6f971eaaa9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/TextItemView.kt @@ -47,7 +47,7 @@ fun MarkdownText ( senderBold: Boolean = false, modifier: Modifier = Modifier ) { - val reserve = if (edited) " " else " " + val reserve = if (edited) " " else " " if (formattedText == null) { val annotatedText = buildAnnotatedString { appendSender(this, sender, senderBold) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt index 306797e628..aa03a7d0cf 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatHelpView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.chat +package chat.simplex.app.views.chatlist import android.content.res.Configuration import androidx.compose.foundation.clickable @@ -10,11 +10,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.* +import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.annotatedStringResource +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.usersettings.simplexTeamUri val bold = SpanStyle(fontWeight = FontWeight.Bold) @@ -27,18 +31,13 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { ) { val uriHandler = LocalUriHandler.current - Text("Thank you for installing SimpleX Chat!") + Text(generalGetString(R.string.thank_you_for_installing_simplex), lineHeight = 22.sp) Text( - buildAnnotatedString { - append("You can ") - withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { - append("connect to SimpleX Chat founder") - } - append(".") - }, + annotatedStringResource(R.string.you_can_connect_to_simplex_chat_founder), modifier = Modifier.clickable(onClick = { uriHandler.openUri(simplexTeamUri) - }) + }), + lineHeight = 22.sp ) Column( @@ -47,33 +46,24 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - "To start a new chat", - style = MaterialTheme.typography.h2 + generalGetString(R.string.to_start_a_new_chat_help_header), + style = MaterialTheme.typography.h2, + lineHeight = 22.sp ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Text("Tap button") + Text(generalGetString(R.string.chat_help_tap_button)) Icon( Icons.Outlined.PersonAdd, - "Add Contact", + generalGetString(R.string.add_contact), modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier, ) - Text("above, then:") + Text(generalGetString(R.string.above_then_preposition_continuation)) } - Text( - buildAnnotatedString { - withStyle(bold) { append("Add new contact") } - append(": to create your one-time QR Code for your contact.") - } - ) - Text( - buildAnnotatedString { - withStyle(bold) { append("Scan QR code") } - append(": to connect to your contact who shows QR code to you.") - } - ) + Text(annotatedStringResource(R.string.add_new_contact_to_create_one_time_QR_code), lineHeight = 22.sp) + Text(annotatedStringResource(R.string.scan_QR_code_to_connect_to_contact_who_shows_QR_code), lineHeight = 22.sp) } Column( @@ -81,24 +71,10 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { horizontalAlignment = Alignment.Start, verticalArrangement = Arrangement.spacedBy(10.dp) ) { - Text("To connect via link", style = MaterialTheme.typography.h2) - Text("If you received SimpleX Chat invitation link you can open it in your browser:") - Text( - buildAnnotatedString { - append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ") - withStyle(bold) { append("Scan QR code") } - append(".") - } - ) - Text( - buildAnnotatedString { - append("\uD83D\uDCF1 mobile: tap ") - withStyle(bold) { append("Open in mobile app") } - append(", then tap ") - withStyle(bold) { append("Connect") } - append(" in the app.") - } - ) + Text(generalGetString(R.string.to_connect_via_link_title), style = MaterialTheme.typography.h2) + Text(generalGetString(R.string.if_you_received_simplex_invitation_link_you_can_open_in_browser), lineHeight = 22.sp) + Text(annotatedStringResource(R.string.desktop_scan_QR_code_from_app_via_scan_QR_code), lineHeight = 22.sp) + Text(annotatedStringResource(R.string.mobile_tap_open_in_mobile_app_then_tap_connect_in_app), lineHeight = 22.sp) } } } @@ -112,6 +88,6 @@ fun ChatHelpView(addContact: (() -> Unit)? = null) { @Composable fun PreviewChatHelpLayout() { SimpleXTheme { - ChatHelpView({}) + ChatHelpView {} } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt index fbd8b235a5..f707e62c8b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListNavLinkView.kt @@ -11,11 +11,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp - +import chat.simplex.app.R import chat.simplex.app.model.* import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import kotlinx.datetime.Clock @Composable @@ -42,9 +41,9 @@ suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) { fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) { AlertManager.shared.showAlertDialog( - title = "Accept connection request?", - text = "If you choose to reject sender will NOT be notified.", - confirmText = "Accept", + title = generalGetString(R.string.accept_connection_request__question), + text = generalGetString(R.string.if_you_choose_to_reject_the_sender_will_not_be_notified), + confirmText = generalGetString(R.string.accept_contact_button), onConfirm = { withApi { val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId) @@ -54,7 +53,7 @@ fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel } } }, - dismissText = "Reject", + dismissText = generalGetString(R.string.reject_contact_button), onDismiss = { withApi { chatModel.controller.apiRejectContactRequest(contactRequest.apiId) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt index 1f1c6b912e..704fcd4df9 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatListView.kt @@ -14,11 +14,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.ToolbarDark import chat.simplex.app.ui.theme.ToolbarLight -import chat.simplex.app.views.chat.ChatHelpView -import chat.simplex.app.views.newchat.ModalManager +import chat.simplex.app.views.helpers.ModalManager +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.newchat.NewChatSheet import chat.simplex.app.views.usersettings.SettingsView import kotlinx.coroutines.CoroutineScope @@ -110,25 +111,28 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) { .fillMaxWidth() .padding(16.dp) ) { + val welcomeMsg = if (displayName != null) { + String.format(generalGetString(R.string.personal_welcome), displayName) + } else generalGetString(R.string.welcome) Text( - text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!", + text = welcomeMsg, Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, color = MaterialTheme.colors.onBackground ) - ChatHelpView({ scaffoldCtrl.toggleSheet() }) + ChatHelpView { scaffoldCtrl.toggleSheet() } Row( Modifier.padding(top = 30.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "This text is available in settings", + generalGetString(R.string.this_text_is_available_in_settings), color = MaterialTheme.colors.onBackground ) Icon( Icons.Outlined.Settings, - "Settings", + generalGetString(R.string.icon_descr_settings), tint = MaterialTheme.colors.onBackground, modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() }) ) @@ -150,13 +154,13 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) { Icon( Icons.Outlined.Menu, - "Settings", + generalGetString(R.string.icon_descr_settings), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) } Text( - "Your chats", + generalGetString(R.string.your_chats), color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(5.dp) @@ -164,7 +168,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) { IconButton(onClick = { scaffoldCtrl.toggleSheet() }) { Icon( Icons.Outlined.PersonAdd, - "Add Contact", + generalGetString(R.string.add_contact), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt index 45f7fe5322..81b10548be 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ChatPreviewView.kt @@ -14,13 +14,13 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chat.item.MarkdownText -import chat.simplex.app.views.helpers.ChatInfoImage -import chat.simplex.app.views.helpers.badgeLayout +import chat.simplex.app.views.helpers.* @Composable fun ChatPreviewView(chat: Chat) { @@ -63,7 +63,7 @@ fun ChatPreviewView(chat: Chat) { val n = chat.chatStats.unreadCount if (n > 0) { Text( - if (n < 1000) "$n" else "${n / 1000}k", + if (n < 1000) "$n" else "${n / 1000}" + generalGetString(R.string.thousand_abbreviation), color = MaterialTheme.colors.onPrimary, fontSize = 14.sp, modifier = Modifier diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt index 4cfa5c48c9..08a7b46795 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chatlist/ContactRequestView.kt @@ -8,10 +8,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.getTimestampText import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.views.helpers.ChatInfoImage +import chat.simplex.app.views.helpers.generalGetString @Composable fun ContactRequestView(chat: Chat) { @@ -31,7 +33,7 @@ fun ContactRequestView(chat: Chat) { color = MaterialTheme.colors.primary ) Text( - "wants to connect to you!", + generalGetString(R.string.contact_wants_to_connect_with_you), maxLines = 2, overflow = TextOverflow.Ellipsis ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt index a746df1925..7e22b00d2d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/AlertManager.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf +import chat.simplex.app.R import chat.simplex.app.TAG class AlertManager { @@ -40,9 +41,9 @@ class AlertManager { fun showAlertDialog( title: String, text: String? = null, - confirmText: String = "Ok", + confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null, - dismissText: String = "Cancel", + dismissText: String = generalGetString(R.string.cancel_verb), onDismiss: (() -> Unit)? = null ) { val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) } @@ -69,7 +70,7 @@ class AlertManager { fun showAlertMsg( title: String, text: String? = null, - confirmText: String = "Ok", onConfirm: (() -> Unit)? = null + confirmText: String = generalGetString(R.string.ok), onConfirm: (() -> Unit)? = null ) { val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) } showAlert { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt index d5d582e9ed..3b92c1bf13 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChatInfoImage.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.Chat import chat.simplex.app.model.ChatInfo import chat.simplex.app.ui.theme.SimpleXTheme @@ -39,7 +40,7 @@ fun ProfileImage( if (image == null) { Icon( icon, - contentDescription = "profile image placeholder", + contentDescription = generalGetString(R.string.icon_descr_profile_image_placeholder), tint = MaterialTheme.colors.secondary, modifier = Modifier.fillMaxSize() ) @@ -47,7 +48,7 @@ fun ProfileImage( val imageBitmap = base64ToBitmap(image).asImageBitmap() Image( imageBitmap, - "profile image", + generalGetString(R.string.image_descr_profile_image), contentScale = ContentScale.Crop, modifier = Modifier.size(size).padding(size / 12).clip(CircleShape) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt index 0ee1884b67..2577ae9251 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/CloseSheetBar.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme @Composable @@ -24,7 +25,7 @@ fun CloseSheetBar(close: () -> Unit) { IconButton(onClick = close) { Icon( Icons.Outlined.Close, - "Close button", + generalGetString(R.string.icon_descr_close_button), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(10.dp) ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt index 6ae0c384a6..21b9166dbe 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/GetImageView.kt @@ -27,37 +27,45 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.core.content.FileProvider -import chat.simplex.app.BuildConfig -import chat.simplex.app.TAG +import chat.simplex.app.* +import chat.simplex.app.R import chat.simplex.app.views.newchat.ActionButton import java.io.ByteArrayOutputStream import java.io.File +import kotlin.math.min +import kotlin.math.sqrt // Inspired by https://github.com/MakeItEasyDev/Jetpack-Compose-Capture-Image-Or-Choose-from-Gallery -fun bitmapToBase64(bitmap: Bitmap, squareCrop: Boolean = true): String { - val size = 104 - var height = size - var width = size +private fun cropToSquare(image: Bitmap): Bitmap { var xOffset = 0 var yOffset = 0 - if (bitmap.height < bitmap.width) { - width = height * bitmap.width / bitmap.height - xOffset = (width - height) / 2 + val side = min(image.height, image.width) + if (image.height < image.width) { + xOffset = (image.width - side) / 2 } else { - height = width * bitmap.height / bitmap.width - yOffset = (height - width) / 2 + yOffset = (image.height - side) / 2 } - var image = bitmap - while (image.width / 2 > width) { - image = Bitmap.createScaledBitmap(image, image.width / 2, image.height / 2, true) - } - image = Bitmap.createScaledBitmap(image, width, height, true) - if (squareCrop) { - image = Bitmap.createBitmap(image, xOffset, yOffset, size, size) + return Bitmap.createBitmap(image, xOffset, yOffset, side, side) +} + +fun resizeImageToDataSize(image: Bitmap, maxDataSize: Int): String { + var img = image + var str = compressImage(img) + while (str.length > maxDataSize) { + val ratio = sqrt(str.length.toDouble() / maxDataSize.toDouble()) + val clippedRatio = min(ratio, 2.0) + val width = (img.width.toDouble() / clippedRatio).toInt() + val height = img.height * width / img.width + img = Bitmap.createScaledBitmap(img, width, height, true) + str = compressImage(img) } + return str +} + +private fun compressImage(bitmap: Bitmap): String { val stream = ByteArrayOutputStream() - image.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bitmap.compress(Bitmap.CompressFormat.JPEG, 85, stream) return "data:image/jpg;base64," + Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) } @@ -126,12 +134,12 @@ fun GetImageBottomSheet( if (uri != null) { val source = ImageDecoder.createSource(context.contentResolver, uri) val bitmap = ImageDecoder.decodeBitmap(source) - profileImageStr.value = bitmapToBase64(bitmap) + profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) } } val cameraLauncher = rememberCameraLauncher { bitmap: Bitmap? -> - if (bitmap != null) profileImageStr.value = bitmapToBase64(bitmap) + if (bitmap != null) profileImageStr.value = resizeImageToDataSize(cropToSquare(bitmap), maxDataSize = 12500) } val permissionLauncher = rememberPermissionLauncher { isGranted: Boolean -> @@ -140,7 +148,7 @@ fun GetImageBottomSheet( else galleryLauncher.launch("image/*") hideBottomSheet() } else { - Toast.makeText(context, "Permission Denied!", Toast.LENGTH_SHORT).show() + Toast.makeText(context, generalGetString(R.string.toast_camera_permission_denied), Toast.LENGTH_SHORT).show() } } @@ -158,7 +166,7 @@ fun GetImageBottomSheet( .padding(horizontal = 8.dp, vertical = 30.dp), horizontalArrangement = Arrangement.SpaceEvenly ) { - ActionButton(null, "Use Camera", icon = Icons.Outlined.PhotoCamera) { + ActionButton(null, generalGetString(R.string.use_camera_button), icon = Icons.Outlined.PhotoCamera) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) -> { cameraLauncher.launch(null) @@ -170,7 +178,7 @@ fun GetImageBottomSheet( } } } - ActionButton(null, "From Gallery", icon = Icons.Outlined.Collections) { + ActionButton(null, generalGetString(R.string.from_gallery_button), icon = Icons.Outlined.Collections) { when (PackageManager.PERMISSION_GRANTED) { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) -> { galleryLauncher.launch("image/*") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt new file mode 100644 index 0000000000..ec13b5dd27 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/LinkPreviews.kt @@ -0,0 +1,141 @@ +package chat.simplex.app.views.helpers + +import android.content.res.Configuration +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Close +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R +import chat.simplex.app.model.LinkPreview +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.chat.item.SentColorLight +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.jsoup.Jsoup + +private const val OG_SELECT_QUERY = "meta[property^=og:]" + +suspend fun getLinkPreview(url: String): LinkPreview? { + return withContext(Dispatchers.IO) { + try { + val response = Jsoup.connect(url) + .ignoreContentType(true) + .timeout(10000) + .followRedirects(true) + .execute() + val doc = response.parse() + val ogTags = doc.select(OG_SELECT_QUERY) + val imageUri = ogTags.firstOrNull { it.attr("property") == "og:image" }?.attr("content") + if (imageUri != null) { + try { + val stream = java.net.URL(imageUri).openStream() + val image = resizeImageToDataSize(BitmapFactory.decodeStream(stream), maxDataSize = 14000) +// TODO add once supported in iOS +// val description = ogTags.firstOrNull { +// it.attr("property") == "og:description" +// }?.attr("content") ?: "" + val title = ogTags.firstOrNull { it.attr("property") == "og:title" }?.attr("content") + if (title != null) { + return@withContext LinkPreview(url, title, description = "", image) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } catch (e: Exception) { + e.printStackTrace() + } + return@withContext null + } +} + + + +@Composable +fun ComposeLinkView(linkPreview: LinkPreview, cancelPreview: () -> Unit) { + Row( + Modifier.fillMaxWidth().padding(top = 8.dp).background(SentColorLight), + verticalAlignment = Alignment.CenterVertically + ) { + val imageBitmap = base64ToBitmap(linkPreview.image).asImageBitmap() + Image( + imageBitmap, + generalGetString(R.string.image_descr_link_preview), + modifier = Modifier.width(80.dp).height(60.dp).padding(end = 8.dp) + ) + Column(Modifier.fillMaxWidth().weight(1F)) { + Text(linkPreview.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2 + ) + } + IconButton(onClick = cancelPreview, modifier = Modifier.padding(0.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = generalGetString(R.string.icon_descr_cancel_link_preview), + tint = MaterialTheme.colors.primary, + modifier = Modifier.padding(10.dp) + ) + } + } +} + +@Composable +fun ChatItemLinkView(linkPreview: LinkPreview) { + Column { + Image( + base64ToBitmap(linkPreview.image).asImageBitmap(), + generalGetString(R.string.image_descr_link_preview), + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth, + ) + Column(Modifier.padding(top = 6.dp).padding(horizontal = 12.dp)) { + Text(linkPreview.title, maxLines = 3, overflow = TextOverflow.Ellipsis, lineHeight = 22.sp, modifier = Modifier.padding(bottom = 4.dp)) + if (linkPreview.description != "") { + Text(linkPreview.description, maxLines = 12, overflow = TextOverflow.Ellipsis, fontSize = 14.sp, lineHeight = 20.sp) + } + Text(linkPreview.uri, maxLines = 1, overflow = TextOverflow.Ellipsis, fontSize = 12.sp, color = HighOrLowlight) + } + } +} + + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "ChatItemLinkView (Dark Mode)" +) +@Composable +fun PreviewChatItemLinkView() { + SimpleXTheme { + ChatItemLinkView(LinkPreview.sampleData) + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + showBackground = true, + name = "ComposeLinkView (Dark Mode)" +) +@Composable +fun PreviewComposeLinkView() { + SimpleXTheme { + ComposeLinkView(LinkPreview.sampleData) { -> } + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt index 298867bc8e..6349354c84 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ModalView.kt @@ -1,4 +1,4 @@ -package chat.simplex.app.views.newchat +package chat.simplex.app.views.helpers import android.util.Log import androidx.activity.compose.BackHandler @@ -11,7 +11,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import chat.simplex.app.TAG -import chat.simplex.app.views.helpers.CloseSheetBar @Composable fun ModalView(close: () -> Unit, content: @Composable () -> Unit) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index 454e880837..c1838b1256 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -1,9 +1,23 @@ package chat.simplex.app.views.helpers +import android.content.res.Resources import android.graphics.Rect +import android.graphics.Typeface +import android.text.Spanned +import android.text.SpannedString +import android.text.style.* import android.view.ViewTreeObserver +import androidx.annotation.StringRes import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.* +import androidx.compose.ui.text.style.BaselineShift +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.* +import androidx.core.text.HtmlCompat +import chat.simplex.app.SimplexApp import kotlinx.coroutines.* fun withApi(action: suspend CoroutineScope.() -> Unit): Job = @@ -38,3 +52,146 @@ fun getKeyboardState(): State { return keyboardState } + +// Resource to annotated string from +// https://stackoverflow.com/questions/68549248/android-jetpack-compose-how-to-show-styled-text-from-string-resources + +fun generalGetString(id: Int) : String { + return SimplexApp.context.getString(id) +} + +@Composable +@ReadOnlyComposable +private fun resources(): Resources { + LocalConfiguration.current + return LocalContext.current.resources +} + +fun Spanned.toHtmlWithoutParagraphs(): String { + return HtmlCompat.toHtml(this, HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE) + .substringAfter("

").substringBeforeLast("

") +} + +fun Resources.getText(@StringRes id: Int, vararg args: Any): CharSequence { + val escapedArgs = args.map { + if (it is Spanned) it.toHtmlWithoutParagraphs() else it + }.toTypedArray() + val resource = SpannedString(getText(id)) + val htmlResource = resource.toHtmlWithoutParagraphs() + val formattedHtml = String.format(htmlResource, *escapedArgs) + return HtmlCompat.fromHtml(formattedHtml, HtmlCompat.FROM_HTML_MODE_LEGACY) +} + +@Composable +fun annotatedStringResource(@StringRes id: Int): AnnotatedString { + val resources = resources() + val density = LocalDensity.current + return remember(id) { + val text = resources.getText(id) + spannableStringToAnnotatedString(text, density) + } +} + +private fun spannableStringToAnnotatedString( + text: CharSequence, + density: Density, +): AnnotatedString { + return if (text is Spanned) { + with(density) { + buildAnnotatedString { + append((text.toString())) + text.getSpans(0, text.length, Any::class.java).forEach { + val start = text.getSpanStart(it) + val end = text.getSpanEnd(it) + when (it) { + is StyleSpan -> when (it.style) { + Typeface.NORMAL -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Normal, + ), + start, + end + ) + Typeface.BOLD -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Normal + ), + start, + end + ) + Typeface.ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Normal, + fontStyle = FontStyle.Italic + ), + start, + end + ) + Typeface.BOLD_ITALIC -> addStyle( + SpanStyle( + fontWeight = FontWeight.Bold, + fontStyle = FontStyle.Italic + ), + start, + end + ) + } + is TypefaceSpan -> addStyle( + SpanStyle( + fontFamily = when (it.family) { + FontFamily.SansSerif.name -> FontFamily.SansSerif + FontFamily.Serif.name -> FontFamily.Serif + FontFamily.Monospace.name -> FontFamily.Monospace + FontFamily.Cursive.name -> FontFamily.Cursive + else -> FontFamily.Default + } + ), + start, + end + ) + is AbsoluteSizeSpan -> addStyle( + SpanStyle(fontSize = if (it.dip) it.size.dp.toSp() else it.size.toSp()), + start, + end + ) + is RelativeSizeSpan -> addStyle( + SpanStyle(fontSize = it.sizeChange.em), + start, + end + ) + is StrikethroughSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.LineThrough), + start, + end + ) + is UnderlineSpan -> addStyle( + SpanStyle(textDecoration = TextDecoration.Underline), + start, + end + ) + is SuperscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Superscript), + start, + end + ) + is SubscriptSpan -> addStyle( + SpanStyle(baselineShift = BaselineShift.Subscript), + start, + end + ) + is ForegroundColorSpan -> addStyle( + SpanStyle(color = Color(it.foregroundColor)), + start, + end + ) + else -> addStyle(SpanStyle(color = Color.White), start, end) + } + } + } + } + } else { + AnnotatedString(text.toString()) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt index 545bf105f4..4e668e3580 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/AddContactView.kt @@ -10,15 +10,16 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.generalGetString import chat.simplex.app.views.helpers.shareText @Composable @@ -42,11 +43,11 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { verticalArrangement = Arrangement.SpaceBetween, ) { Text( - "Add contact", + generalGetString(R.string.add_contact), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), ) Text( - "Show QR code to your contact\nto scan from the app", + generalGetString(R.string.show_QR_code_for_your_contact_to_scan_from_the_app__multiline), style = MaterialTheme.typography.h3, textAlign = TextAlign.Center, ) @@ -57,20 +58,14 @@ fun AddContactLayout(connReq: String, share: () -> Unit) { .padding(vertical = 3.dp) ) Text( - buildAnnotatedString { - append("If you cannot meet in person, you can ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append("scan QR code in the video call") - } - append(", or you can share the invitation link via any other channel.") - }, + generalGetString(R.string.if_you_cannot_meet_in_person_show_QR_in_video_call_or_via_another_channel), textAlign = TextAlign.Center, - style = MaterialTheme.typography.caption.copy(fontSize=if(screenHeight > 600.dp) 20.sp else 16.sp), + lineHeight = 22.sp, modifier = Modifier .padding(horizontal = 16.dp) .padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp) ) - SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share) + SimpleButton(generalGetString(R.string.share_invitation_link), icon = Icons.Outlined.Share, click = share) Spacer(Modifier.height(10.dp)) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt index 06f34cb0a6..f65b000987 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/ConnectContactView.kt @@ -8,15 +8,15 @@ import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* @Composable fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) { @@ -31,8 +31,8 @@ fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) { } } catch (e: RuntimeException) { AlertManager.shared.showAlertMsg( - title = "Invalid QR code", - text = "This QR code is not a link!" + title = generalGetString(R.string.invalid_QR_code), + text = generalGetString(R.string.this_QR_code_is_not_a_link) ) } close() @@ -48,8 +48,8 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) { withApi { run(action) } } else { AlertManager.shared.showAlertMsg( - title = "Invalid link!", - text = "This link is not a valid connection link!" + title = generalGetString(R.string.invalid_contact_link), + text = generalGetString(R.string.this_link_is_not_a_valid_connection_link) ) } } @@ -57,12 +57,11 @@ fun withUriAction(uri: Uri, run: suspend (String) -> Unit) { suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) { val r = chatModel.controller.apiConnect(uri.toString()) if (r) { - val whenConnected = - if (action == "contact") "your connection request is accepted" - else "your contact's device is online" AlertManager.shared.showAlertMsg( - title = "Connection request sent!", - text = "You will be connected when $whenConnected, please wait or check later!" + title = generalGetString(R.string.connection_request_sent), + text = + if (action == "contact") generalGetString(R.string.you_will_be_connected_when_your_connection_request_is_accepted) + else generalGetString(R.string.you_will_be_connected_when_your_contacts_device_is_online) ) } } @@ -75,11 +74,11 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni verticalArrangement = Arrangement.spacedBy(12.dp) ) { Text( - "Scan QR code", + generalGetString(R.string.scan_QR_code), style = MaterialTheme.typography.h1.copy(fontWeight = FontWeight.Normal), ) Text( - "Your chat profile will be sent\nto your contact", + generalGetString(R.string.your_chat_profile_will_be_sent_to_your_contact), style = MaterialTheme.typography.h3, textAlign = TextAlign.Center, modifier = Modifier.padding(bottom = 4.dp) @@ -90,18 +89,8 @@ fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Uni .aspectRatio(ratio = 1F) ) { qrCodeScanner() } Text( - buildAnnotatedString { - append("If you cannot meet in person, you can ") - withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { - append("scan QR code in the video call") - } - append(", or you can create the invitation link.") - }, - textAlign = TextAlign.Center, - style = MaterialTheme.typography.caption, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp) + annotatedStringResource(R.string.if_you_cannot_meet_in_person_scan_QR_in_video_call_or_ask_for_invitation_link), + lineHeight = 22.sp ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt index 61d2400a01..2d3ff4924a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/NewChatSheet.kt @@ -14,11 +14,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.chatlist.ScaffoldController -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* import com.google.accompanist.permissions.rememberPermissionState @Composable @@ -57,8 +58,10 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { .weight(1F) .fillMaxWidth()) { ActionButton( - "Add contact", "(create QR code\nor link)", - Icons.Outlined.PersonAdd, click = addContact + generalGetString(R.string.add_contact), + generalGetString(R.string.create_QR_code_or_link__bracketed__multiline), + Icons.Outlined.PersonAdd, + click = addContact ) } Box( @@ -66,8 +69,10 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { .weight(1F) .fillMaxWidth()) { ActionButton( - "Scan QR code", "(in person or in video call)", - Icons.Outlined.QrCode, click = scanCode + generalGetString(R.string.scan_QR_code), + generalGetString(R.string.in_person_or_in_video_call__bracketed), + Icons.Outlined.QrCode, + click = scanCode ) } Box( @@ -75,8 +80,10 @@ fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) { .weight(1F) .fillMaxWidth()) { ActionButton( - "Create Group", "(coming soon!)", - Icons.Outlined.GroupAdd, disabled = true + generalGetString(R.string.create_group), + generalGetString(R.string.coming_soon__bracketed), + Icons.Outlined.GroupAdd, + disabled = true ) } } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt index cbc56268f4..f55489c7d7 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/newchat/QRCode.kt @@ -7,7 +7,9 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.tooling.preview.Preview +import chat.simplex.app.R import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.generalGetString import com.google.zxing.BarcodeFormat import com.google.zxing.EncodeHintType import com.google.zxing.qrcode.QRCodeWriter @@ -16,7 +18,7 @@ import com.google.zxing.qrcode.QRCodeWriter fun QRCode(connReq: String, modifier: Modifier = Modifier) { Image( bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(), - contentDescription = "QR Code", + contentDescription = generalGetString(R.string.image_descr_qr_code), modifier = modifier ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt index 9d7d380c7a..dddb7bef80 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/HelpView.kt @@ -3,6 +3,8 @@ package chat.simplex.app.views.usersettings import android.content.res.Configuration import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -10,9 +12,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.chat.ChatHelpView +import chat.simplex.app.views.chatlist.ChatHelpView +import chat.simplex.app.views.helpers.generalGetString @Composable fun HelpView(chatModel: ChatModel) { @@ -24,9 +28,12 @@ fun HelpView(chatModel: ChatModel) { @Composable fun HelpLayout(displayName: String) { - Column(horizontalAlignment = Alignment.Start) { + Column( + Modifier.verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.Start + ){ Text( - "Welcome $displayName!", + String.format(generalGetString(R.string.personal_welcome), displayName), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, ) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt index ef6e29a6d4..b1cb3e68f2 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/MarkdownHelpView.kt @@ -10,29 +10,38 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.* import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import chat.simplex.app.R import chat.simplex.app.model.Format import chat.simplex.app.model.FormatColor import chat.simplex.app.ui.theme.SimpleXTheme +import chat.simplex.app.views.helpers.generalGetString @Composable fun MarkdownHelpView() { Column { Text( - "How to use markdown", + generalGetString(R.string.how_to_use_markdown), style = MaterialTheme.typography.h1, ) Text( - "You can use markdown to format messages:", + generalGetString(R.string.you_can_use_markdown_to_format_messages__prompt), Modifier.padding(vertical = 16.dp) ) - MdFormat("*bold*", "bold", Format.Bold()) - MdFormat("_italic_", "italic", Format.Italic()) - MdFormat("~strike~", "strike", Format.StrikeThrough()) - MdFormat("`a + b`", "a + b", Format.Snippet()) + val bold = generalGetString(R.string.bold) + val italic = generalGetString(R.string.italic) + val strikethrough = generalGetString(R.string.strikethrough) + val equation = generalGetString(R.string.a_plus_b) + val colored = generalGetString(R.string.colored) + val secret = generalGetString(R.string.secret) + + MdFormat("*$bold*", bold, Format.Bold()) + MdFormat("_${italic}_", italic, Format.Italic()) + MdFormat("~$strikethrough~", strikethrough, Format.StrikeThrough()) + MdFormat("`$equation`", equation, Format.Snippet()) Row { - MdSyntax("!1 colored!") + MdSyntax("!1 $colored!") Text(buildAnnotatedString { - withStyle(Format.Colored(FormatColor.red).style) { append("colored") } + withStyle(Format.Colored(FormatColor.red).style) { append(colored) } append(" (") appendColor(this, "1", FormatColor.red, ", ") appendColor(this, "2", FormatColor.green, ", ") @@ -43,10 +52,10 @@ fun MarkdownHelpView() { }) } Row { - MdSyntax("#secret#") + MdSyntax("#$secret#") SelectionContainer { Text(buildAnnotatedString { - withStyle(Format.Secret().style) { append("secret") } + withStyle(Format.Secret().style) { append(secret) } }) } } @@ -56,7 +65,7 @@ fun MarkdownHelpView() { @Composable fun MdSyntax(markdown: String) { Text(markdown, Modifier - .width(100.dp) + .width(120.dp) .padding(bottom = 4.dp)) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt index f9a6b4c9ce..6668018792 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SMPServers.kt @@ -20,11 +20,11 @@ import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme -import chat.simplex.app.views.helpers.AlertManager -import chat.simplex.app.views.helpers.withApi +import chat.simplex.app.views.helpers.* @Composable fun SMPServersView(chatModel: ChatModel) { @@ -60,9 +60,9 @@ fun SMPServersView(chatModel: ChatModel) { if (userSMPServers != null) { if (userSMPServers.isNotEmpty()) { AlertManager.shared.showAlertMsg( - title = "Use SimpleX Chat servers?", - text = "Saved SMP servers will be removed.", - confirmText = "Confirm", + title = generalGetString(R.string.use_simplex_chat_servers__question), + text = generalGetString(R.string.saved_SMP_servers_will_br_removed), + confirmText = generalGetString(R.string.confirm_verb), onConfirm = { saveSMPServers(listOf()) isUserSMPServers = false @@ -108,14 +108,14 @@ fun SMPServersLayout( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Your SMP servers", + generalGetString(R.string.your_SMP_servers), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1 ) Row( verticalAlignment = Alignment.CenterVertically ) { - Text("Configure SMP servers", Modifier.padding(end = 24.dp)) + Text(generalGetString(R.string.configure_SMP_servers), Modifier.padding(end = 24.dp)) Switch( checked = isUserSMPServers, onCheckedChange = isUserSMPServersOnOff, @@ -127,9 +127,9 @@ fun SMPServersLayout( } if (!isUserSMPServers) { - Text("Using SimpleX Chat servers.") + Text(generalGetString(R.string.using_simplex_chat_servers), lineHeight = 22.sp) } else { - Text("Enter one SMP server per line:") + Text(generalGetString(R.string.enter_one_SMP_server_per_line)) if (editSMPServers) { BasicTextField( value = userSMPServersStr, @@ -173,14 +173,14 @@ fun SMPServersLayout( Column(horizontalAlignment = Alignment.Start) { Row { Text( - "Cancel", + generalGetString(R.string.cancel_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = cancelEdit) ) Spacer(Modifier.padding(horizontal = 8.dp)) Text( - "Save", + generalGetString(R.string.save_servers_button), color = MaterialTheme.colors.primary, modifier = Modifier.clickable(onClick = { val servers = userSMPServersStr.split("\n") @@ -219,7 +219,7 @@ fun SMPServersLayout( ) { Column(horizontalAlignment = Alignment.Start) { Text( - "Edit", + generalGetString(R.string.edit_verb), color = MaterialTheme.colors.primary, modifier = Modifier .clickable(onClick = editOn) @@ -241,9 +241,9 @@ fun howToButton() { verticalAlignment = Alignment.CenterVertically, modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") } ) { - Text("How to", color = MaterialTheme.colors.primary) + Text(generalGetString(R.string.how_to), color = MaterialTheme.colors.primary) Icon( - Icons.Outlined.OpenInNew, "How to", tint = MaterialTheme.colors.primary, + Icons.Outlined.OpenInNew, generalGetString(R.string.how_to), tint = MaterialTheme.colors.primary, modifier = Modifier.padding(horizontal = 5.dp) ) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt index e74aa7042e..a99a49c99b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/SettingsView.kt @@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp @@ -24,8 +23,7 @@ import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.HighOrLowlight import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.TerminalView -import chat.simplex.app.views.helpers.ProfileImage -import chat.simplex.app.views.newchat.ModalManager +import chat.simplex.app.views.helpers.* @Composable fun SettingsView(chatModel: ChatModel) { @@ -71,7 +69,7 @@ fun SettingsLayout( .padding(top = 16.dp) ) { Text( - "Your settings", + generalGetString(R.string.your_settings), style = MaterialTheme.typography.h1, modifier = Modifier.padding(start = 8.dp) ) @@ -93,39 +91,39 @@ fun SettingsLayout( SettingsSectionView(showModal { UserAddressView(it) }) { Icon( Icons.Outlined.QrCode, - contentDescription = "Address", + contentDescription = generalGetString(R.string.icon_descr_address), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Your SimpleX contact address") + Text(generalGetString(R.string.your_simplex_contact_address)) } Spacer(Modifier.height(24.dp)) SettingsSectionView(showModal { HelpView(it) }) { Icon( Icons.Outlined.HelpOutline, - contentDescription = "Chat help", + contentDescription = generalGetString(R.string.icon_descr_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("How to use SimpleX Chat") + Text(generalGetString(R.string.how_to_use_simplex_chat)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView(showModal { MarkdownHelpView() }) { Icon( Icons.Outlined.TextFormat, - contentDescription = "Markdown help", + contentDescription = generalGetString(R.string.markdown_help), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Markdown in messages") + Text(generalGetString(R.string.markdown_in_messages)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) { Icon( Icons.Outlined.Tag, - contentDescription = "SimpleX Team", + contentDescription = generalGetString(R.string.icon_descr_simplex_team), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - "Chat with the founder", + generalGetString(R.string.chat_with_the_founder), color = MaterialTheme.colors.primary ) } @@ -133,11 +131,11 @@ fun SettingsLayout( SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) { Icon( Icons.Outlined.Email, - contentDescription = "Email", + contentDescription = generalGetString(R.string.icon_descr_email), ) Spacer(Modifier.padding(horizontal = 4.dp)) Text( - "Send us email", + generalGetString(R.string.send_us_an_email), color = MaterialTheme.colors.primary ) } @@ -146,19 +144,20 @@ fun SettingsLayout( SettingsSectionView(showModal { SMPServersView(it) }) { Icon( Icons.Outlined.Dns, - contentDescription = "SMP servers", + contentDescription = generalGetString(R.string.smp_servers), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("SMP servers") + Text(generalGetString(R.string.smp_servers)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView() { Icon( Icons.Outlined.Bolt, - contentDescription = "Private notifications", + contentDescription = generalGetString(R.string.private_notifications), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Private notifications", Modifier + Text( + generalGetString(R.string.private_notifications), Modifier .padding(end = 24.dp) .fillMaxWidth() .weight(1F)) @@ -176,10 +175,10 @@ fun SettingsLayout( SettingsSectionView(showTerminal) { Icon( painter = painterResource(id = R.drawable.ic_outline_terminal), - contentDescription = "Chat console", + contentDescription = generalGetString(R.string.chat_console), ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text("Chat console") + Text(generalGetString(R.string.chat_console)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) { @@ -188,14 +187,7 @@ fun SettingsLayout( contentDescription = "GitHub", ) Spacer(Modifier.padding(horizontal = 4.dp)) - Text( - buildAnnotatedString { - append("Install ") - withStyle(SpanStyle(color = MaterialTheme.colors.primary)) { - append("SimpleX Chat for terminal") - } - } - ) + Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal)) } Divider(Modifier.padding(horizontal = 8.dp)) SettingsSectionView() { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt index 486260220a..734fa19a05 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserAddressView.kt @@ -13,6 +13,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.ui.theme.SimpleButton import chat.simplex.app.ui.theme.SimpleXTheme @@ -32,9 +34,9 @@ fun UserAddressView(chatModel: ChatModel) { share = { userAddress: String -> shareText(cxt, userAddress) }, deleteAddress = { AlertManager.shared.showAlertMsg( - title = "Delete address?", - text = "All your contacts will remain connected.", - confirmText = "Delete", + title = generalGetString(R.string.delete_address__question), + text = generalGetString(R.string.all_your_contacts_will_remain_connected), + confirmText = generalGetString(R.string.delete_verb), onConfirm = { withApi { chatModel.controller.apiDeleteUserAddress() @@ -58,14 +60,14 @@ fun UserAddressLayout( verticalArrangement = Arrangement.Top ) { Text( - "Your chat address", + generalGetString(R.string.your_chat_address), Modifier.padding(bottom = 16.dp), style = MaterialTheme.typography.h1, ) Text( - "You can share your address as a link or as a QR code - anybody will be able to connect to you, " + - "and if you later delete it - you won't lose your contacts.", + generalGetString(R.string.you_can_share_your_address_anybody_will_be_able_to_connect), Modifier.padding(bottom = 12.dp), + lineHeight = 22.sp ) Column( Modifier.fillMaxWidth(), @@ -73,7 +75,12 @@ fun UserAddressLayout( verticalArrangement = Arrangement.SpaceEvenly ) { if (userAddress == null) { - SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress) + Text( + generalGetString(R.string.if_you_delete_address_you_wont_lose_contacts), + Modifier.padding(bottom = 12.dp), + lineHeight = 22.sp + ) + SimpleButton(generalGetString(R.string.create_address), icon = Icons.Outlined.QrCode, click = createAddress) } else { QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f)) Row( @@ -82,11 +89,11 @@ fun UserAddressLayout( modifier = Modifier.padding(vertical = 10.dp) ) { SimpleButton( - "Share link", + generalGetString(R.string.share_link), icon = Icons.Outlined.Share, click = { share(userAddress) }) SimpleButton( - "Delete address", + generalGetString(R.string.delete_address), icon = Icons.Outlined.Delete, color = Color.Red, click = deleteAddress diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt index 46ddaffe89..39af8a3c21 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/UserProfileView.kt @@ -19,11 +19,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.R import chat.simplex.app.model.ChatModel import chat.simplex.app.model.Profile import chat.simplex.app.ui.theme.SimpleXTheme import chat.simplex.app.views.helpers.* -import chat.simplex.app.views.newchat.ModalView import com.google.accompanist.insets.ProvideWindowInsets import com.google.accompanist.insets.navigationBarsWithImePadding import kotlinx.coroutines.launch @@ -89,16 +90,16 @@ fun UserProfileLayout( horizontalAlignment = Alignment.Start ) { Text( - "Your chat profile", + generalGetString(R.string.your_chat_profile), Modifier.padding(bottom = 24.dp), style = MaterialTheme.typography.h1, color = MaterialTheme.colors.onBackground ) Text( - "Your profile is stored on your device and shared only with your contacts.\n\n" + - "SimpleX servers cannot see your profile.", + generalGetString(R.string.your_profile_is_stored_on_device_and_shared_only_with_contacts_simplex_cannot_see_it), Modifier.padding(bottom = 24.dp), - color = MaterialTheme.colors.onBackground + color = MaterialTheme.colors.onBackground, + lineHeight = 22.sp ) if (editProfile.value) { Column( @@ -124,14 +125,14 @@ fun UserProfileLayout( ProfileNameTextField(displayName) ProfileNameTextField(fullName) Row { - TextButton("Cancel") { + TextButton(generalGetString(R.string.cancel_verb)) { displayName.value = profile.displayName fullName.value = profile.fullName profileImage.value = profile.image editProfile.value = false } Spacer(Modifier.padding(horizontal = 8.dp)) - TextButton("Save (and notify contacts)") { + TextButton(generalGetString(R.string.save_and_notify_contacts)) { saveProfile(displayName.value, fullName.value, profileImage.value) } } @@ -154,9 +155,9 @@ fun UserProfileLayout( } } } - ProfileNameRow("Display name:", profile.displayName) - ProfileNameRow("Full name:", profile.fullName) - TextButton("Edit") { editProfile.value = true } + ProfileNameRow(generalGetString(R.string.display_name__field), profile.displayName) + ProfileNameRow(generalGetString(R.string.full_name__field), profile.fullName) + TextButton(generalGetString(R.string.edit_verb)) { editProfile.value = true } } } if (savedKeyboardState != keyboardState) { @@ -223,7 +224,7 @@ fun EditImageButton(click: () -> Unit) { ) { Icon( Icons.Outlined.PhotoCamera, - contentDescription = "Edit image", + contentDescription = generalGetString(R.string.edit_image), tint = MaterialTheme.colors.primary, modifier = Modifier.size(36.dp) ) @@ -235,7 +236,7 @@ fun DeleteImageButton(click: () -> Unit) { IconButton(onClick = click) { Icon( Icons.Outlined.Close, - contentDescription = "Delete image", + contentDescription = generalGetString(R.string.delete_image), tint = MaterialTheme.colors.primary, ) } diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000000..1d54970f46 --- /dev/null +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,218 @@ + + SimpleX + + Ρ‚ + + + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку-ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚? + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку-ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅? + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ, ΠΎΡ‚ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ³ΠΎ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΠ»ΠΈ эту ссылку. + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ + + + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ установлСно + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ устанавливаСтся… + УстановлСно соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°. + УстанавливаСтся соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π° (ошибка: %1$s). + УстанавливаСтся соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°. + + + ΡƒΠ΄Π°Π»Π΅Π½ΠΎ + ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ° Ρ„Π°ΠΉΠ»ΠΎΠ² Π½Π΅ поддСрТиваСтся + ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ Ρ„Π°ΠΉΠ»ΠΎΠ² Π½Π΅ поддСрТиваСтся + Π²Ρ‹ + нСизвСстный Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ сообщСния + Π½Π΅Π²Π΅Ρ€Π½Ρ‹ΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ сообщСния + + + Ошибка ΠΏΡ€ΠΈ сохранСнии SMP сСрвСров + ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅, Ρ‡Ρ‚ΠΎ адрСса SMP сСрвСров ΠΈΠΌΠ΅ΡŽΡ‚ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚, ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ адрСс Π½Π° ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ строкС ΠΈ Π½Π΅ повторяСтся. + + + Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ + Π’Ρ‹ ΡƒΠΆΠ΅ соСдинСны с %1$s! Ρ‡Π΅Ρ€Π΅Π· эту ссылку. + Ошибка Π² ссылкС ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π° + ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅, Ρ‡Ρ‚ΠΎ Π²Ρ‹ использовали ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½ΡƒΡŽ ссылку, ΠΈΠ»ΠΈ попроситС ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π²Π°ΠΌ Π½ΠΎΠ²ΡƒΡŽ. + НСвозмоТно ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚! + ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚ %1$s! Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ ΡƒΠ΄Π°Π»Π΅Π½, Ρ‚Π°ΠΊ ΠΊΠ°ΠΊ являСтся Ρ‡Π»Π΅Π½ΠΎΠΌ Π³Ρ€ΡƒΠΏΠΏ(Ρ‹) %2$s. + ΠœΠ³Π½ΠΎΠ²Π΅Π½Π½Ρ‹Π΅ увСдомлСния + + + ΠŸΡ€ΠΈΠ²Π°Ρ‚Π½Ρ‹Π΅ ΠΌΠ³Π½ΠΎΠ²Π΅Π½Π½Ρ‹Π΅ увСдомлСния! + Π§Ρ‚ΠΎΠ±Ρ‹ Π·Π°Ρ‰ΠΈΡ‚ΠΈΡ‚ΡŒ ваши Π»ΠΈΡ‡Π½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅, вмСсто ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠΉ ΠΎΡ‚ сСрвСра ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ запускаСт Ρ„ΠΎΠ½ΠΎΠ²Ρ‹ΠΉ сСрвис SimpleX, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ потрСбляСт нСсколько ΠΏΡ€ΠΎΡ†Π΅Π½Ρ‚ΠΎΠ² Π±Π°Ρ‚Π°Ρ€Π΅ΠΈ Π² дСнь. + Он ΠΌΠΎΠΆΠ΅Ρ‚ Π±Ρ‹Ρ‚ΡŒ Π²Ρ‹ΠΊΠ»ΡŽΡ‡Π΅Π½ Ρ‡Π΅Ρ€Π΅Π· Настройки – Π²Ρ‹ ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚Π΅ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ увСдомлСния ΠΎ сообщСниях ΠΏΠΎΠΊΠ° ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ Π·Π°ΠΏΡƒΡ‰Π΅Π½ΠΎ. + + + SimpleX Chat сСрвис + ΠŸΡ€ΠΈΡ‘ΠΌ сообщСний… + + + ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ + ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ + Π‘ΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ сообщСниС? + Π‘ΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ ΡƒΠ΄Π°Π»Π΅Π½ΠΎ – это дСйствиС нСльзя ΠΎΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ! + Волько для мСня + Для всСх + + + ΠΎΡ‚Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΎ + ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½ΠΎ + ошибка Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·Π°Ρ†ΠΈΠΈ ΠΏΡ€ΠΈ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ΅ + ошибка ΠΏΡ€ΠΈ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ΅ + Π½Π΅ ΠΏΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½ΠΎ + + + ЗдравствуйтС %1$s! + ЗдравствуйтС! + Π­Ρ‚ΠΎΡ‚ тСкст ΠΌΠΎΠΆΠ½ΠΎ Π½Π°ΠΉΡ‚ΠΈ Π² Настройках + Π’Π°ΡˆΠΈ Ρ‡Π°Ρ‚Ρ‹ + + + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚? + ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΈ всС сообщСния Π±ΡƒΠ΄ΡƒΡ‚ ΡƒΠ΄Π°Π»Π΅Π½Ρ‹ - это дСйствиС нСльзя ΠΎΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ! + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ с сСрвСром установлСно + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ с сСрвСром Π½Π΅ установлСно + Ошибка соСдинСния с сСрвСром + ΠžΠΆΠΈΠ΄Π°Π΅Ρ‚ΡΡ соСдинСниС с сСрвСром + + + ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ сообщСниС + + + Назад + ΠžΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ + ΠŸΠΎΠ΄Ρ‚Π²Π΅Ρ€Π΄ΠΈΡ‚ΡŒ + OΠΊ + Π½Π΅Ρ‚ описания + Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ + Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ + + + (ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ ΠΈΠ»ΠΈ ссылку) + (ΠΏΡ€ΠΈ встрСчС ΠΈΠ»ΠΈ Ρ‡Π΅Ρ€Π΅Π· Π²ΠΈΠ΄Π΅ΠΎ Π·Π²ΠΎΠ½ΠΎΠΊ) + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π³Ρ€ΡƒΠΏΠΏΡƒ + (скоро!) + + + Π Π°Π·Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ Π½Π΅ ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΎ! + Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ + ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π³Π°Π»Π΅Ρ€Π΅ΡŽ + + + Бпасибо Ρ‡Ρ‚ΠΎ установили SimpleX Chat! + Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ°ΠΌΠΈ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π·Π°Π΄Π°Ρ‚ΡŒ Π»ΡŽΠ±Ρ‹Π΅ вопросы ΠΈΠ»ΠΈ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ увСдомлСния ΠΎ Π½ΠΎΠ²Ρ‹Ρ… вСрсиях. + Π§Ρ‚ΠΎΠ±Ρ‹ Π½Π°Ρ‡Π°Ρ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ Ρ‡Π°Ρ‚ + НаТмитС ΠΊΠ½ΠΎΠΏΠΊΡƒ + свСрху, Π·Π°Ρ‚Π΅ΠΌ: + Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚: Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΎΠ΄Π½ΠΎΡ€Π°Π·ΠΎΠ²Ρ‹ΠΉ QR ΠΊΠΎΠ΄/ссылку для вашСго ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°. + Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄: Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ΠΎΠΌ, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Π²Π°ΠΌ QR ΠΊΠΎΠ΄. + Π§Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку + Если Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΠ»ΠΈ ссылку с ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ΠΌ ΠΈΠ· SimpleX Chat, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π΅Π΅ Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π΅: + πŸ’» Π½Π° ΠΊΠΎΠΌΠΏΡŒΡŽΡ‚Π΅Ρ€Π΅: сосканируйтС ΠΏΠΎΠΊΠ°Π·Π°Π½Π½Ρ‹ΠΉ QR ΠΊΠΎΠ΄ ΠΈΠ· прилоТСния Ρ‡Π΅Ρ€Π΅Π· Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄. + πŸ“± Π½Π° мобильном: Π½Π°ΠΌΠΆΠΈΡ‚Π΅ ΠΊΠ½ΠΎΠΏΠΊΡƒ Open in mobile app Π½Π° Π²Π΅Π± страницС, Π·Π°Ρ‚Π΅ΠΌ Π½Π°ΠΆΠΌΠΈΡ‚Π΅ Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Π² ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΈ. + + + ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ запрос Π½Π° соСдинСниС? + ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚Π΅Π»ΡŽ НЕ Π±ΡƒΠ΄Π΅Ρ‚ послано ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅, Ссли Π²Ρ‹ ΠΎΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚Π΅ запрос Π½Π° соСдинСниС. + ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ + ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ + + + Ρ…ΠΎΡ‡Π΅Ρ‚ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Π²Π°ΠΌΠΈ! + + + Π°Π²Π°Ρ‚Π°Ρ€ Π½Π΅ установлСн + Π°Π²Π°Ρ‚Π°Ρ€ + + + Π·Π°ΠΊΡ€Ρ‹Ρ‚ΡŒ + ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΏΡ€Π΅Π²ΡŒΡŽ ссылки + ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ ΠΏΡ€Π΅Π²ΡŒΡŽ ссылки + Настройки + QR ΠΊΠΎΠ΄ + SimpleX адрСс + ΠŸΠΎΠΌΠΎΡ‰ΡŒ + SimpleX ΠΊΠΎΠΌΠ°Π½Π΄Π° + SimpleX Π»ΠΎΠ³ΠΎΡ‚ΠΈΠΏ + Email + + + НСвСрный QR ΠΊΠΎΠ΄ + Π­Ρ‚ΠΎΡ‚ QR ΠΊΠΎΠ΄ Π½Π΅ являСтся ссылкой! + НСвСрная ссылка! + Π­Ρ‚Π° ссылка Π½Π΅ являСтся ссылкой-ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ΠΌ! + Запрос Π½Π° соСдинСниС послан! + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ установлСно ΠΊΠΎΠ³Π΄Π° ваш запрос Π±ΡƒΠ΄Π΅Ρ‚ принят. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅! + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ установлСно ΠΊΠΎΠ³Π΄Π° ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΠ½Π»Π°ΠΉΠ½. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅! + ΠŸΠΎΠΊΠ°ΠΆΠΈΡ‚Π΅ QR ΠΊΠΎΠ΄ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΡΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π΅Π³ΠΎ ΠΈΠ· прилоТСния + Если Π²Ρ‹ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Π²ΡΡ‚Ρ€Π΅Ρ‚ΠΈΡ‚ΡŒΡΡ Π»ΠΈΡ‡Π½ΠΎ, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ Π²ΠΎ врСмя Π²ΠΈΠ΄Π΅ΠΎ Π·Π²ΠΎΠ½ΠΊΠ° ΠΈΠ»ΠΈ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ ссылку Ρ‡Π΅Ρ€Π΅Π· любой Π΄Ρ€ΡƒΠ³ΠΎΠΉ ΠΊΠ°Π½Π°Π» связи. + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½\nΠ²Π°ΡˆΠ΅ΠΌΡƒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ + Если Π²Ρ‹ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Π²ΡΡ‚Ρ€Π΅Ρ‚ΠΈΡ‚ΡŒΡΡ Π»ΠΈΡ‡Π½ΠΎ, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΡΠΎΡΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ Π²ΠΎ врСмя Π²ΠΈΠ΄Π΅ΠΎ Π·Π²ΠΎΠ½ΠΊΠ°, ΠΈΠ»ΠΈ ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π²Π°ΠΌ ссылку. + ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ ссылкой + + + Настройки + Π’Π°Ρˆ SimpleX адрСс + Как ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ SimpleX Chat + Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ сообщСний + Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ сообщСний + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ°ΠΌΠΈ + ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ email + ΠŸΡ€ΠΈΠ²Π°Ρ‚Π½Ρ‹Π΅ увСдомлСния + Консоль + SMP сСрвСры + SimpleX Chat для Ρ‚Π΅Ρ€ΠΌΠΈΠ½Π°Π»Π° + Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ сСрвСры прСдосталСнныС SimpleX Chat? + Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½Π½Ρ‹Π΅ SMP сСрвСры Π±ΡƒΠ΄ΡƒΡ‚ ΡƒΠ΄Π°Π»Π΅Π½Ρ‹. + Π’Π°ΡˆΠΈ SMP сСрвСры + Настройка SMP сСрвСров + Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΡŽΡ‚ΡΡ сСрвСры прСдоставлСнныС SimpleX Chat. + Π’Π²Π΅Π΄ΠΈΡ‚Π΅ SMP сСрвСры, ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ сСрвСр Π² ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ строкС: + Π˜Π½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡ + Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ + + + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ адрСс + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ адрСс? + ВсС ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ соСдинились Ρ‡Π΅Ρ€Π΅Π· этот адрСс, сохранятся. + Π’Π°Ρˆ SimpleX адрСс + Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ адрСс ΠΊΠ°ΠΊ ссылку ΠΈΠ»ΠΈ ΠΊΠ°ΠΊ QR ΠΊΠΎΠ΄ - Ρ‡Π΅Ρ€Π΅Π· Π½Π΅Π³ΠΎ ΠΌΠΎΠΆΠ½ΠΎ с Π²Π°ΠΌΠΈ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ. + Π’Ρ‹ смоТСтС ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ адрСс, сохранив ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ Ρ‡Π΅Ρ€Π΅Π· Π½Π΅Π³ΠΎ соСдинились. + ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ\nссылкой + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ\nадрСс + + + Имя профиля: + "ПолноС имя: + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ хранится Π½Π° вашСм устройствС ΠΈ отправляСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°ΠΌ.\n\nSimpleX сСрвСры Π½Π΅ ΠΌΠΎΠ³ΡƒΡ‚ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ доступ ΠΊ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŽ. + ΠŸΠΎΠΌΠ΅Π½ΡΡ‚ΡŒ Π°Π²Π°Ρ‚Π°Ρ€ + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ Π°Π²Π°Ρ‚Π°Ρ€ + Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ (ΠΈ ΠΏΠΎΡΠ»Π°Ρ‚ΡŒ ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°ΠΌ) + + + Π’Ρ‹ ΠΊΠΎΡ‚Ρ€ΠΎΠ»ΠΈΡ€ΡƒΠ΅Ρ‚Π΅ ваш Ρ‡Π°Ρ‚! + ΠŸΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΠ° для сообщСний ΠΈ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΉ, которая Π·Π°Ρ‰ΠΈΡ‰Π°Π΅Ρ‚ Π²Π°ΡˆΡƒ Π»ΠΈΡ‡Π½ΡƒΡŽ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ ΠΈ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡ‚ΡŒ. + ΠœΡ‹ Π½Π΅ Ρ…Ρ€Π°Π½ΠΈΠΌ ваши ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ ΠΈ сообщСния (послС доставки) Π½Π° сСрвСрах. + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ хранится Π½Π° вашСм устройствС ΠΈ отправляСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°ΠΌ. + Имя профиля Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ ΠΏΡ€ΠΎΠ±Π΅Π»Ρ‹. + Имя профиля + ПолноС имя (Π½Π΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ) + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ + + + Как Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ сообщСния: + ΠΆΠΈΡ€Π½Ρ‹ΠΉ + курсив + Π·Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚ΡŒ + a + b + Ρ†Π²Π΅Ρ‚ + сСкрСт + + diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 228c03d113..dda6fd3439 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -1,7 +1,218 @@ - - SimpleX + + SimpleX + + k + + + Connect via contact link? + Connect via invitation link? + Your profile will be sent to the contact that you received this link from. + Connect + + + Server connected + Connecting server… + You are connected to the server used to receive messages from this contact. + Trying to connect to the server used to receive messages from this contact (error: %1$s). + Trying to connect to the server used to receive messages from this contact. + + + deleted + sending files is not supported yet + receiving files is not supported yet + you + unknown message format + invalid message format + + + Error saving SMP servers + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + + + Contact already exists + You are already connected to %1$s! via this link. + Invalid connection link + Please check that you used the correct link or ask your contact to send you another one. + Can\'t delete contact! + Contact %1$s! cannot be deleted, they are a member of the group(s) %2$s. + Instant notifications + + + Private instant notifications! + To preserve your privacy, instead of push notifications the app has a SimpleX background service – it uses a few percent of the battery per day. + It can be disabled via settings – notifications will still be shown while the app is running. - SimpleX Chat service - Waiting for incoming messages + SimpleX Chat service + Receiving messages… + + + Reply + Share + Copy + Edit + Delete + Delete message? + Message will be deleted - this cannot be undone! + For me only + For everybody + + + edited + sent + unauthorized send + send failed + unread + + + Welcome %1$s! + Welcome! + This text is available in settings + Your chats + + + Delete contact? + Contact and all messages will be deleted - this cannot be undone! + Delete contact + Connected + Disconnected + Error + Pending + + + Send Message + + + Back + Cancel + Confirm + Ok + no details + Add contact + Scan QR code + + + (create QR code\nor link) + (in person or in video call) + Create Group + (coming soon!) + + + Permission Denied! + Use Camera + From Gallery + + + Thank you for installing SimpleX Chat! + You can connect to SimpleX Chat developers to ask any questions and to receive updates. + To start a new chat + Tap button + above, then: + Add new contact: to create your one-time QR Code for your contact. + Scan QR code: to connect to your contact who shows QR code to you. + To connect via link + If you received SimpleX Chat invitation link, you can open it in your browser: + πŸ’» desktop: scan displayed QR code from the app, via Scan QR code. + πŸ“± mobile: tap Open in mobile app, then tap Connect in the app. + + + Accept connection request? + If you choose to reject sender will NOT be notified. + Accept + Reject + + + wants to connect to you! + + + profile image placeholder + profile image + + + Close button + link preview image + cancel link preview + Settings + QR Code + SimpleX Address + help + SimpleX Team + SimpleX Logo + Email + + + Invalid QR code + This QR code is not a link! + Invalid link! + This link is not a valid connection link! + Connection request sent! + You will be connected when your connection request is accepted, please wait or check later! + You will be connected when your contact\'s device is online, please wait or check later! + Show QR code for your contact\nto scan from the app + If you cannot meet in person, you can show QR code in the video call, or you can share the invitation link via any other channel. + Your chat profile will be sent\nto your contact + If you cannot meet in person, you can scan QR code in the video call, or your contact can share an invitation link. + Share invitation link + + + Your settings + Your SimpleX contact address + How to use SimpleX Chat + Markdown help + Markdown in messages + Connect to the developers + Send us email + Private notifications + Chat console + SMP servers + Install SimpleX Chat for terminal + Use SimpleX Chat servers? + Saved SMP servers will be removed. + Your SMP servers + Configure SMP servers + Using SimpleX Chat servers. + Enter one SMP server per line: + How to + Save + + + Create address + Delete address? + All your contacts will remain connected. + Your chat address + You can share your address as a link or as a QR code - anybody will be able to connect to you. + If you later delete it - you won\'t lose your contacts. + Share link + Delete address + + + Display name: + "Full name: + Your chat profile + Your profile is stored on your device and shared only with your contacts.\n\nSimpleX servers cannot see your profile. + Edit image + Delete image + Save (and notify contacts) + + + You control your chat! + The messaging and application platform protecting your privacy and security. + We don\'t store any of your contacts or messages (once delivered) on the servers. + Create profile + Your profile is stored on your device and shared only with your contacts. + Display name cannot contain whitespace. + Display Name + Full Name (Optional) + Create + + + How to use markdown + You can use markdown to format messages: + bold + italic + strike + a + b + colored + secret + diff --git a/apps/android/build.gradle b/apps/android/build.gradle index ad0b119f62..231378f393 100644 --- a/apps/android/build.gradle +++ b/apps/android/build.gradle @@ -7,7 +7,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.1.2' + classpath 'com.android.tools.build:gradle:7.1.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10" classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2" @@ -16,8 +16,8 @@ buildscript { } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { - id 'com.android.application' version '7.1.2' apply false - id 'com.android.library' version '7.1.2' apply false + id 'com.android.application' version '7.1.3' apply false + id 'com.android.library' version '7.1.3' apply false id 'org.jetbrains.kotlin.android' version '1.6.10' apply false id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10' } diff --git a/apps/ios/LOCALIZATION.md b/apps/ios/LOCALIZATION.md new file mode 100644 index 0000000000..40fe37c215 --- /dev/null +++ b/apps/ios/LOCALIZATION.md @@ -0,0 +1,29 @@ +# Localization + +## Creating localization keys + +There are three ways XCode generates localization keys from strings: + +1. Automatically, from the texts used in standard components `Text`, `Label`, `Button`, etc. + +2. All strings passed to view variables and function parameters declared as `LocalizedStringKey` type. Only string constants (possibly, with interpolation) or other variables of type `LocalizedStringKey` can be passed to these parameters. See, for example, ContentView.swift. + +3. All strings wrapped in `NSLocalizedString`. Please note that such strings do not support swift interpolation, instead formatted strings should be used: + +```swift +String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body") +``` + +## Adding strings to the existing localizations + +1. Choose `Product -> Export Localizations...` in the menu, choose `ios` folder as the destination and `SimpleX Localizations` as the folder name, confirm to overwrite it (make sure not to save to subfolder). +2. Add `target` keys to the localizations that were added or changed. +3. Choose `Product -> Import Localizations...` for any non-Enlish folders - that would update Localizable files. + +Localizable files values can be edited directly, the changes will be included in the next export. Following the process above though guarantees that all strings are localized. + +## Development + +Make sure to enable the option `Show non-localized strings` in `Product -> Scheme -> Edit scheme...` menu - it will be showing all non-localized strings as all caps. + +Read more about editing XLIFF and string files here: https://developer.apple.com/documentation/xcode/editing-xliff-and-strings-files diff --git a/apps/ios/Shared/ContentView.swift b/apps/ios/Shared/ContentView.swift index b9e7fb066d..7d911d4c82 100644 --- a/apps/ios/Shared/ContentView.swift +++ b/apps/ios/Shared/ContentView.swift @@ -36,7 +36,7 @@ struct ContentView: View { func notificationAlert() -> Alert { Alert( - title: Text("Notification are disabled!"), + title: Text("Notifications are disabled!"), message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."), primaryButton: .default(Text("Open Settings")) { DispatchQueue.main.async { @@ -61,7 +61,7 @@ final class AlertManager: ObservableObject { } } - func showAlertMsg(title: String, message: String? = nil) { + func showAlertMsg(title: LocalizedStringKey, message: LocalizedStringKey? = nil) { if let message = message { showAlert(Alert(title: Text(title), message: Text(message))) } else { diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 1d52b2ab40..e05806458e 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -384,7 +384,7 @@ final class Chat: ObservableObject, Identifiable { case disconnected case error(String) - var statusString: String { + var statusString: LocalizedStringKey { get { switch self { case .connected: return "Server connected" @@ -394,7 +394,7 @@ final class Chat: ObservableObject, Identifiable { } } - var statusExplanation: String { + var statusExplanation: LocalizedStringKey { get { switch self { case .connected: return "You are connected to the server used to receive messages from this contact." @@ -719,10 +719,19 @@ enum CIContent: Decodable, ItemContent { switch self { case let .sndMsgContent(mc): return mc.text case let .rcvMsgContent(mc): return mc.text - case .sndDeleted: return "deleted" - case .rcvDeleted: return "deleted" - case .sndFileInvitation: return "sending files is not supported yet" - case .rcvFileInvitation: return "receiving files is not supported yet" + case .sndDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .rcvDeleted: return NSLocalizedString("deleted", comment: "deleted chat item") + case .sndFileInvitation: return NSLocalizedString("sending files is not supported yet", comment: "to be removed") + case .rcvFileInvitation: return NSLocalizedString("receiving files is not supported yet", comment: "to be removed") + } + } + } + var msgContent: MsgContent? { + get { + switch self { + case let .sndMsgContent(mc): return mc + case let .rcvMsgContent(mc): return mc + default: return nil } } } @@ -761,6 +770,7 @@ struct CIQuote: Decodable, ItemContent { enum MsgContent { case text(String) + case link(text: String, preview: LinkPreview) // TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift case unknown(type: String, text: String) @@ -768,6 +778,7 @@ enum MsgContent { get { switch self { case let .text(text): return text + case let .link(text, _): return text case let .unknown(_, text): return text } } @@ -777,6 +788,8 @@ enum MsgContent { get { switch self { case let .text(text): return "text \(text)" + case let .link(text: text, preview: preview): + return "json {\"type\":\"link\",\"text\":\(encodeJSON(text)),\"preview\":\(encodeJSON(preview))}" default: return "" } } @@ -785,9 +798,11 @@ enum MsgContent { enum CodingKeys: String, CodingKey { case type case text + case preview } } +// TODO define Encodable extension MsgContent: Decodable { init(from decoder: Decoder) throws { do { @@ -797,6 +812,10 @@ extension MsgContent: Decodable { case "text": let text = try container.decode(String.self, forKey: CodingKeys.text) self = .text(text) + case "link": + let text = try container.decode(String.self, forKey: CodingKeys.text) + let preview = try container.decode(LinkPreview.self, forKey: CodingKeys.preview) + self = .link(text: text, preview: preview) default: let text = try? container.decode(String.self, forKey: CodingKeys.text) self = .unknown(type: type, text: text ?? "unknown message format") @@ -812,7 +831,7 @@ struct FormattedText: Decodable { var format: Format? } -enum Format: Decodable { +enum Format: Decodable, Equatable { case bold case italic case strikeThrough @@ -849,3 +868,12 @@ enum FormatColor: String, Decodable { } } } + +// Struct to use with simplex API +struct LinkPreview: Codable { + var uri: URL + var title: String + // TODO remove once optional in haskell + var description: String = "" + var image: String +} diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index 5d53cb31a9..8861968b2c 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -92,22 +92,22 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { identifier: ntfCategoryContactRequest, actions: [UNNotificationAction( identifier: ntfActionAccept, - title: "Accept" + title: NSLocalizedString("Accept", comment: "accept contact request via notification") )], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: "New contact request" + hiddenPreviewsBodyPlaceholder: NSLocalizedString("New contact request", comment: "notification") ), UNNotificationCategory( identifier: ntfCategoryContactConnected, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: "Contact is connected" + hiddenPreviewsBodyPlaceholder: NSLocalizedString("Contact is connected", comment: "notification") ), UNNotificationCategory( identifier: ntfCategoryMessageReceived, actions: [], intentIdentifiers: [], - hiddenPreviewsBodyPlaceholder: "New message" + hiddenPreviewsBodyPlaceholder: NSLocalizedString("New message", comment: "notifications") ) ]) } @@ -139,8 +139,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.notifyContactRequest") addNotification( categoryIdentifier: ntfCategoryContactRequest, - title: "\(contactRequest.displayName) wants to connect!", - body: "Accept contact request from \(contactRequest.chatViewName)?", + title: String.localizedStringWithFormat(NSLocalizedString("%@ wants to connect!", comment: "notification title"), contactRequest.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("Accept contact request from %@?", comment: "notification body"), contactRequest.chatViewName), targetContentIdentifier: nil, userInfo: ["chatId": contactRequest.id, "contactRequestId": contactRequest.apiId] ) @@ -150,8 +150,8 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { logger.debug("NtfManager.notifyContactConnected") addNotification( categoryIdentifier: ntfCategoryContactConnected, - title: "\(contact.displayName) is connected!", - body: "You can now send messages to \(contact.chatViewName)", + title: String.localizedStringWithFormat(NSLocalizedString("%@ is connected!", comment: "notification title"), contact.displayName), + body: String.localizedStringWithFormat(NSLocalizedString("You can now send messages to %@", comment: "notification body"), contact.chatViewName), targetContentIdentifier: contact.id // userInfo: ["chatId": contact.id, "contactId": contact.apiId] ) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 68f38e423e..7622ac881d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -13,7 +13,7 @@ import BackgroundTasks private var chatController: chat_ctrl? private let jsonDecoder = getJSONDecoder() -private let jsonEncoder = getJSONEncoder() +let jsonEncoder = getJSONEncoder() enum ChatCommand { case showActiveUser @@ -31,6 +31,7 @@ enum ChatCommand { case connect(connReq: String) case apiDeleteChat(type: ChatType, id: Int64) case apiUpdateProfile(profile: Profile) + case apiParseMarkdown(text: String) case createMyAddress case deleteMyAddress case showMyAddress @@ -57,6 +58,7 @@ enum ChatCommand { case let .connect(connReq): return "/connect \(connReq)" case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiUpdateProfile(profile): return "/_profile \(encodeJSON(profile))" + case let .apiParseMarkdown(text): return "/_parse \(text)" case .createMyAddress: return "/address" case .deleteMyAddress: return "/delete_address" case .showMyAddress: return "/show_address" @@ -86,6 +88,7 @@ enum ChatCommand { case .connect: return "connect" case .apiDeleteChat: return "apiDeleteChat" case .apiUpdateProfile: return "apiUpdateProfile" + case .apiParseMarkdown: return "apiParseMarkdown" case .createMyAddress: return "createMyAddress" case .deleteMyAddress: return "deleteMyAddress" case .showMyAddress: return "showMyAddress" @@ -125,6 +128,7 @@ enum ChatResponse: Decodable, Error { case contactDeleted(contact: Contact) case userProfileNoChange case userProfileUpdated(fromProfile: Profile, toProfile: Profile) + case apiParsedMarkdown(formattedText: [FormattedText]?) case userContactLink(connReqContact: String) case userContactLinkCreated(connReqContact: String) case userContactLinkDeleted @@ -166,6 +170,7 @@ enum ChatResponse: Decodable, Error { case .contactDeleted: return "contactDeleted" case .userProfileNoChange: return "userProfileNoChange" case .userProfileUpdated: return "userProfileUpdated" + case .apiParsedMarkdown: return "apiParsedMarkdown" case .userContactLink: return "userContactLink" case .userContactLinkCreated: return "userContactLinkCreated" case .userContactLinkDeleted: return "userContactLinkDeleted" @@ -210,6 +215,7 @@ enum ChatResponse: Decodable, Error { case let .contactDeleted(contact): return String(describing: contact) case .userProfileNoChange: return noDetails case let .userProfileUpdated(_, toProfile): return String(describing: toProfile) + case let .apiParsedMarkdown(formattedText): return String(describing: formattedText) case let .userContactLink(connReq): return connReq case let .userContactLinkCreated(connReq): return connReq case .userContactLinkDeleted: return noDetails @@ -323,9 +329,11 @@ func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = if case let .response(_, json) = resp { logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)") } - DispatchQueue.main.async { - ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) - ChatModel.shared.terminalItems.append(.resp(.now, resp)) + if case .apiParseMarkdown = cmd {} else { + DispatchQueue.main.async { + ChatModel.shared.terminalItems.append(.cmd(.now, cmd)) + ChatModel.shared.terminalItems.append(.resp(.now, resp)) + } } return resp } @@ -459,13 +467,13 @@ func apiConnect(connReq: String) async throws -> Bool { case .chatCmdError(.errorAgent(.BROKER(.TIMEOUT))): am.showAlertMsg( title: "Connection timeout", - message: "Please check your network connection and try again" + message: "Please check your network connection and try again." ) return false case .chatCmdError(.errorAgent(.BROKER(.NETWORK))): am.showAlertMsg( title: "Connection error", - message: "Please check your network connection and try again" + message: "Please check your network connection and try again." ) return false default: throw r @@ -487,6 +495,12 @@ func apiUpdateProfile(profile: Profile) async throws -> Profile? { } } +func apiParseMarkdown(text: String) throws -> [FormattedText]? { + let r = chatSendCmdSync(.apiParseMarkdown(text: text)) + if case let .apiParsedMarkdown(formattedText) = r { return formattedText } + throw r +} + func apiCreateUserAddress() async throws -> String { let r = await chatSendCmd(.createMyAddress) if case let .userContactLinkCreated(connReq) = r { return connReq } @@ -774,7 +788,7 @@ private func getJSONObject(_ cjson: UnsafePointer) -> NSDictionary? { return try? JSONSerialization.jsonObject(with: d) as? NSDictionary } -private func encodeJSON(_ value: T) -> String { +func encodeJSON(_ value: T) -> String { let data = try! jsonEncoder.encode(value) return String(decoding: data, as: UTF8.self) } diff --git a/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h b/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h deleted file mode 100644 index bc28b42d38..0000000000 --- a/apps/ios/Shared/SimpleX (macOS)-Bridging-Header.h +++ /dev/null @@ -1,11 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - -extern void hs_init(int argc, char **argv[]); - -typedef void* chat_ctrl; - -extern chat_ctrl chat_init(char *path); -extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); -extern char *chat_recv_msg(chat_ctrl ctl); diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index ec52ac977a..f345f458fb 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -64,7 +64,7 @@ struct ChatInfoView: View { private func deleteContactAlert(_ contact: Contact) -> Alert { Alert( title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted"), + message: Text("Contact and all messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { Task { do { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index d011edc787..0567598c03 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -8,8 +8,8 @@ import SwiftUI -private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) -private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) +let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12) +let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17) private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11) private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09) @@ -51,6 +51,9 @@ struct FramedItemView: View { .frame(minWidth: msgWidth, alignment: .center) .padding(.bottom, 2) } else { + if case let .link(_, preview) = chatItem.content.msgContent { + ChatItemLinkView(linkPreview: preview) + } MsgContentView( content: chatItem.content, formattedText: chatItem.formattedText, @@ -84,7 +87,7 @@ struct FramedItemView: View { } } - private func msgDeliveryError(_ err: String) { + private func msgDeliveryError(_ err: LocalizedStringKey) { AlertManager.shared.showAlertMsg( title: "Message delivery error", message: err diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 7463178efb..04cb218ede 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -17,6 +17,7 @@ struct ChatView: View { @State var message: String = "" @State var quotedItem: ChatItem? = nil @State var editingItem: ChatItem? = nil + @State var linkPreview: LinkPreview? = nil @State var deletingItem: ChatItem? = nil @State private var inProgress: Bool = false @FocusState private var keyboardVisible: Bool @@ -85,6 +86,7 @@ struct ChatView: View { message: $message, quotedItem: $quotedItem, editingItem: $editingItem, + linkPreview: $linkPreview, sendMessage: sendMessage, resetMessage: { message = "" }, inProgress: inProgress, @@ -98,7 +100,7 @@ struct ChatView: View { Button { chatModel.chatId = nil } label: { HStack(spacing: 4) { Image(systemName: "chevron.backward") - Text("Chats") + Text("Chats", comment: "back button to return to chats list") } } } @@ -200,7 +202,7 @@ struct ChatView: View { } } - func sendMessage(_ msg: String) { + func sendMessage(_ text: String) { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") @@ -210,21 +212,29 @@ struct ChatView: View { type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, itemId: ei.id, - msg: .text(msg) + msg: .text(text) ) DispatchQueue.main.async { editingItem = nil + linkPreview = nil let _ = chatModel.upsertChatItem(chat.chatInfo, chatItem) } } else { + let mc: MsgContent + if let preview = linkPreview { + mc = .link(text: text, preview: preview) + } else { + mc = .text(text) + } let chatItem = try await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, quotedItemId: quotedItem?.meta.itemId, - msg: .text(msg) + msg: mc ) DispatchQueue.main.async { quotedItem = nil + linkPreview = nil chatModel.addChatItem(chat.chatInfo, chatItem) } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 3dc3e1b71a..72c5e66449 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -19,21 +19,34 @@ struct ComposeView: View { @Binding var message: String @Binding var quotedItem: ChatItem? @Binding var editingItem: ChatItem? + @Binding var linkPreview: LinkPreview? + var sendMessage: (String) -> Void var resetMessage: () -> Void var inProgress: Bool = false @FocusState.Binding var keyboardVisible: Bool @State var editing: Bool = false + @State var linkUrl: URL? = nil + @State var prevLinkUrl: URL? = nil + @State var pendingLinkUrl: URL? = nil + @State var cancelledLinks: Set = [] + var body: some View { VStack(spacing: 0) { + if let metadata = linkPreview { + ComposeLinkView(linkPreview: metadata, cancelPreview: cancelPreview) + } if (quotedItem != nil) { ContextItemView(contextItem: $quotedItem, editing: $editing) } else if (editingItem != nil) { ContextItemView(contextItem: $editingItem, editing: $editing, resetMessage: resetMessage) } SendMessageView( - sendMessage: sendMessage, + sendMessage: { text in + sendMessage(text) + resetLinkPreview() + }, inProgress: inProgress, message: $message, keyboardVisible: $keyboardVisible, @@ -41,10 +54,79 @@ struct ComposeView: View { ) .background(.background) } + .onChange(of: message) { _ in + if message.count > 0 { + showLinkPreview(message) + } else { + resetLinkPreview() + } + } .onChange(of: editingItem == nil) { _ in editing = (editingItem != nil) } } + + private func showLinkPreview(_ s: String) { + prevLinkUrl = linkUrl + linkUrl = parseMessage(s) + if let url = linkUrl { + if url != linkPreview?.uri && url != pendingLinkUrl { + pendingLinkUrl = url + if prevLinkUrl == url { + loadLinkPreview(url) + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + loadLinkPreview(url) + } + } + } + } else { + linkPreview = nil + } + } + + private func parseMessage(_ msg: String) -> URL? { + do { + let parsedMsg = try apiParseMarkdown(text: msg) + let uri = parsedMsg?.first(where: { ft in + ft.format == .uri && !cancelledLinks.contains(ft.text) && !isSimplexLink(ft.text) + }) + if let uri = uri { return URL(string: uri.text) } + else { return nil } + } catch { + logger.error("apiParseMarkdown error: \(error.localizedDescription)") + return nil + } + } + + private func isSimplexLink(_ link: String) -> Bool { + link.starts(with: "https://simplex.chat") || link.starts(with: "http://simplex.chat") + } + + private func cancelPreview() { + if let uri = linkPreview?.uri.absoluteString { + cancelledLinks.insert(uri) + } + linkPreview = nil + } + + private func loadLinkPreview(_ url: URL) { + if pendingLinkUrl == url { + getLinkPreview(url: url) { lp in + if pendingLinkUrl == url { + linkPreview = lp + pendingLinkUrl = nil + } + } + } + } + + private func resetLinkPreview() { + linkUrl = nil + prevLinkUrl = nil + pendingLinkUrl = nil + cancelledLinks = [] + } } struct ComposeView_Previews: PreviewProvider { @@ -53,12 +135,14 @@ struct ComposeView_Previews: PreviewProvider { @FocusState var keyboardVisible: Bool @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") @State var nilItem: ChatItem? = nil + @State var linkPreview: LinkPreview? = nil return Group { ComposeView( message: $message, quotedItem: $item, editingItem: $nilItem, + linkPreview: $linkPreview, sendMessage: { print ($0) }, resetMessage: {}, keyboardVisible: $keyboardVisible @@ -67,6 +151,7 @@ struct ComposeView_Previews: PreviewProvider { message: $message, quotedItem: $nilItem, editingItem: $item, + linkPreview: $linkPreview, sendMessage: { print ($0) }, resetMessage: {}, keyboardVisible: $keyboardVisible diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index a760195fa9..afaa4788b3 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -11,7 +11,7 @@ import SwiftUI struct SendMessageView: View { var sendMessage: (String) -> Void var inProgress: Bool = false - @Binding var message: String //Lorem ipsum dolor sit amet, consectetur" // adipiscing elit, sed do eiusmod tempor incididunt ut labor7 et dolore magna aliqua. Ut enim ad minim veniam, quis"// nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."// Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." + @Binding var message: String @Namespace var namespace @FocusState.Binding var keyboardVisible: Bool @Binding var editing: Bool @@ -91,7 +91,6 @@ struct SendMessageView_Previews: PreviewProvider { @State var editingOff: Bool = false @State var editingOn: Bool = true @State var item: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello") - @State var nilItem: ChatItem? = nil return Group { VStack { diff --git a/apps/ios/Shared/Views/ChatList/ChatHelp.swift b/apps/ios/Shared/Views/ChatList/ChatHelp.swift index 58a43eb6cf..3a9dc58f64 100644 --- a/apps/ios/Shared/Views/ChatList/ChatHelp.swift +++ b/apps/ios/Shared/Views/ChatList/ChatHelp.swift @@ -16,9 +16,9 @@ struct ChatHelp: View { VStack(alignment: .leading, spacing: 10) { Text("Thank you for installing SimpleX Chat!") - HStack(spacing: 4) { - Text("You can") - Button("connect to SimpleX Chat founder.") { + VStack(alignment: .leading, spacing: 0) { + Text("To ask any questions and to receive updates:") + Button("connect to SimpleX Chat developers.") { showSettings = false DispatchQueue.main.async { UIApplication.shared.open(simplexTeamURL) diff --git a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift index d2ec6c11e2..d25ffafe53 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListNavLink.swift @@ -116,7 +116,7 @@ struct ChatListNavLink: View { private func deleteContactAlert(_ contact: Contact) -> Alert { Alert( title: Text("Delete contact?"), - message: Text("Contact and all messages will be deleted"), + message: Text("Contact and all messages will be deleted - this cannot be undone!"), primaryButton: .destructive(Text("Delete")) { Task { do { diff --git a/apps/ios/Shared/Views/ChatList/ChatListView.swift b/apps/ios/Shared/Views/ChatList/ChatListView.swift index f74e94a957..e220882494 100644 --- a/apps/ios/Shared/Views/ChatList/ChatListView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatListView.swift @@ -20,19 +20,13 @@ struct ChatListView: View { let v = NavigationView { List { if chatModel.chats.isEmpty { - VStack(alignment: .leading) { - ChatHelp(showSettings: $showSettings) - HStack { - Text("This text is available in settings") - SettingsButton() - } - .padding(.leading) + ChatHelp(showSettings: $showSettings) + } else { + ForEach(filteredChats()) { chat in + ChatListNavLink(chat: chat) + .padding(.trailing, -16) } } - ForEach(filteredChats()) { chat in - ChatListNavLink(chat: chat) - .padding(.trailing, -16) - } } .onChange(of: chatModel.chatId) { _ in if chatModel.chatId == nil, let chatId = chatModel.chatToTop { @@ -80,22 +74,23 @@ struct ChatListView: View { logger.debug("ChatListView.connectViaUrlAlert path: \(path)") if (path == "/contact" || path == "/invitation") { path.removeFirst() - let action = path + let action: ConnReqType = path == "contact" ? .contact : .invitation let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") + let title: LocalizedStringKey + if case .contact = action { title = "Connect via contact link?" } + else { title = "Connect via invitation link?" } return Alert( - title: Text("Connect via \(action) link?"), + title: Text(title), message: Text("Your profile will be sent to the contact that you received this link from"), primaryButton: .default(Text("Connect")) { DispatchQueue.main.async { Task { do { let ok = try await apiConnect(connReq: link) - if ok { - connectionReqSentAlert(action == "contact" ? .contact : .invitation) - } + if ok { connectionReqSentAlert(action) } } catch { let err = error.localizedDescription - AlertManager.shared.showAlertMsg(title: "Connection error", message: err) + AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(err)") logger.debug("ChatListView.connectViaUrlAlert: apiConnect error: \(err)") } } diff --git a/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift new file mode 100644 index 0000000000..09db349295 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ChatItemLinkView.swift @@ -0,0 +1,53 @@ +// +// LargeLinkPreviewView.swift +// SimpleX +// +// Created by Ian Davies on 07/04/2022. +// Copyright Β© 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI + +struct ChatItemLinkView: View { + @Environment(\.colorScheme) var colorScheme + let linkPreview: LinkPreview + + var body: some View { + VStack(alignment: .center, spacing: 6) { + if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + } + VStack(alignment: .leading, spacing: 6) { + Text(linkPreview.title) + .lineLimit(3) +// if linkPreview.description != "" { +// Text(linkPreview.description) +// .font(.subheadline) +// .lineLimit(12) +// } + Text(linkPreview.uri.absoluteString) + .font(.caption) + .lineLimit(1) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} + +struct LargeLinkPreview_Previews: PreviewProvider { + static var previews: some View { + let preview = LinkPreview( + uri: URL(string: "http://DuckDuckGo.com")!, + title: "Privacy, simplified.", + description: "", + image: "data:image/jpg;base64,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" + ) + ChatItemLinkView(linkPreview: preview) + .previewLayout(.fixed(width: 360, height: 200)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift new file mode 100644 index 0000000000..1a9c446499 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/ComposeLinkView.swift @@ -0,0 +1,91 @@ +// +// LinkPreview.swift +// SimpleX +// +// Created by Ian Davies on 04/04/2022. +// Copyright Β© 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import LinkPresentation + + +func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) { + logger.debug("getLinkMetadata: fetching URL preview") + LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in + if let e = error { + logger.error("Error retrieving link metadata: \(e.localizedDescription)") + } + if let metadata = metadata, + let imageProvider = metadata.imageProvider, + imageProvider.canLoadObject(ofClass: UIImage.self) { + imageProvider.loadObject(ofClass: UIImage.self){ object, error in + var linkPreview: LinkPreview? = nil + if let error = error { + logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)") + } else { + if let image = object as? UIImage, + let resized = resizeImageToDataSize(image, maxDataSize: 14000), + let title = metadata.title, + let uri = metadata.originalURL { + linkPreview = LinkPreview(uri: uri, title: title, image: resized) + } + } + cb(linkPreview) + } + } else { + cb(nil) + } + } +} + +struct ComposeLinkView: View { + @Environment(\.colorScheme) var colorScheme + let linkPreview: LinkPreview + var cancelPreview: (() -> Void)? = nil + + var body: some View { + HStack(alignment: .center, spacing: 8) { + if let data = Data(base64Encoded: dropImagePrefix(linkPreview.image)), + let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 80, maxHeight: 60) + } + VStack(alignment: .center, spacing: 4) { + Text(linkPreview.title) + .lineLimit(1) + Text(linkPreview.uri.absoluteString) + .font(.caption) + .lineLimit(1) + .foregroundColor(.secondary) + } + .padding(.vertical, 5) + .frame(maxWidth: .infinity) + if let cancelPreview = cancelPreview { + Button { cancelPreview() } label: { + Image(systemName: "multiply") + } + } + } + .padding(.vertical, 1) + .padding(.trailing, 12) + .background(colorScheme == .light ? sentColorLight : sentColorDark) + .frame(maxWidth: .infinity) + .padding(.top, 8) + } +} + +struct SmallLinkPreview_Previews: PreviewProvider { + static var previews: some View { + let preview = LinkPreview( + uri: URL(string: "http://DuckDuckGo.com")!, + title: "Privacy, simplified.", + description: "", + image: "data:image/jpg;base64,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" + ) + ComposeLinkView(linkPreview: preview, cancelPreview: {}) + .previewLayout(.fixed(width: 360, height: 200)) + } +} diff --git a/apps/ios/Shared/Views/Helpers/ImagePicker.swift b/apps/ios/Shared/Views/Helpers/ImagePicker.swift index 5bd16f693b..7fa0bc722b 100644 --- a/apps/ios/Shared/Views/Helpers/ImagePicker.swift +++ b/apps/ios/Shared/Views/Helpers/ImagePicker.swift @@ -7,44 +7,161 @@ // import SwiftUI +import PhotosUI -struct ImagePicker: UIViewControllerRepresentable { - @Environment(\.presentationMode) var presentationMode - var source: UIImagePickerController.SourceType +func dropPrefix(_ s: String, _ prefix: String) -> String { + s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s +} + +func dropImagePrefix(_ s: String) -> String { + dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") +} + +private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1.0 + format.opaque = true + return UIGraphicsImageRenderer(bounds: newBounds, format: format).image { _ in + image.draw(in: drawIn) + } +} + +func cropToSquare(_ image: UIImage) -> UIImage { + let size = image.size + let side = min(size.width, size.height) + let newSize = CGSize(width: side, height: side) + var origin = CGPoint.zero + if size.width > side { + origin.x -= (size.width - side) / 2 + } else if size.height > side { + origin.y -= (size.height - side) / 2 + } + return resizeImage(image, newBounds: CGRect(origin: .zero, size: newSize), drawIn: CGRect(origin: origin, size: size)) +} + + +func reduceSize(_ image: UIImage, ratio: CGFloat) -> UIImage { + let newSize = CGSize(width: floor(image.size.width / ratio), height: floor(image.size.height / ratio)) + let bounds = CGRect(origin: .zero, size: newSize) + return resizeImage(image, newBounds: bounds, drawIn: bounds) +} + +func resizeImageToDataSize(_ image: UIImage, maxDataSize: Int) -> String? { + var img = image + var str = compressImage(img) + var dataSize = str?.count ?? 0 + while dataSize != 0 && dataSize > maxDataSize { + let ratio = sqrt(Double(dataSize) / Double(maxDataSize)) + let clippedRatio = min(ratio, 2.0) + img = reduceSize(img, ratio: clippedRatio) + str = compressImage(img) + dataSize = str?.count ?? 0 + } + logger.debug("resizeImageToDataSize final \(dataSize)") + return str +} + +func compressImage(_ image: UIImage, _ compressionQuality: CGFloat = 0.85) -> String? { + if let data = image.jpegData(compressionQuality: compressionQuality) { + return "data:image/jpg;base64,\(data.base64EncodedString())" + } + return nil +} + +enum ImageSource { + case imageLibrary + case camera +} + +struct LibraryImagePicker: UIViewControllerRepresentable { + typealias UIViewControllerType = PHPickerViewController @Binding var image: UIImage? - @Binding var imageUrl: URL? - - class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { - let parent: ImagePicker - - init(_ parent: ImagePicker) { + var didFinishPicking: (_ didSelectItems: Bool) -> Void + + class Coordinator: PHPickerViewControllerDelegate { + let parent: LibraryImagePicker + + init(_ parent: LibraryImagePicker) { self.parent = parent } - + + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + parent.didFinishPicking(!results.isEmpty) + guard !results.isEmpty else { + return + } + + if let chosenImageProvider = results.first?.itemProvider { + if chosenImageProvider.canLoadObject(ofClass: UIImage.self) { + chosenImageProvider.loadObject(ofClass: UIImage.self) { [weak self] image, error in + DispatchQueue.main.async { + self?.loadImage(object: image, error: error) + } + } + } + } + } + + func loadImage(object: Any?, error: Error? = nil) { + if let error = error { + logger.error("Couldn't load image with error: \(error.localizedDescription)") + } + parent.image = object as? UIImage + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeUIViewController(context: Context) -> PHPickerViewController { + var config = PHPickerConfiguration() + config.filter = .images + config.selectionLimit = 1 + let controller = PHPickerViewController(configuration: config) + controller.delegate = context.coordinator + return controller + } + + func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) { + + } +} + + +struct CameraImagePicker: UIViewControllerRepresentable { + @Environment(\.presentationMode) var presentationMode + @Binding var image: UIImage? + + class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate { + let parent: CameraImagePicker + + init(_ parent: CameraImagePicker) { + self.parent = parent + } + func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { if let uiImage = info[.originalImage] as? UIImage { - parent.imageUrl = info[.imageURL] as? URL parent.image = uiImage } parent.presentationMode.wrappedValue.dismiss() } } - + func makeCoordinator() -> Coordinator { Coordinator(self) } - - func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { + func makeUIViewController(context: UIViewControllerRepresentableContext) -> UIImagePickerController { let picker = UIImagePickerController() - picker.sourceType = source + picker.sourceType = .camera picker.allowsEditing = false picker.delegate = context.coordinator return picker } - func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { - + func updateUIViewController(_ uiViewController: UIImagePickerController, context: UIViewControllerRepresentableContext) { + } } diff --git a/apps/ios/Shared/Views/Helpers/ProfileImage.swift b/apps/ios/Shared/Views/Helpers/ProfileImage.swift index 74abaca4b9..f8f75d74d2 100644 --- a/apps/ios/Shared/Views/Helpers/ProfileImage.swift +++ b/apps/ios/Shared/Views/Helpers/ProfileImage.swift @@ -26,14 +26,6 @@ struct ProfileImage: View { .foregroundColor(color) } } - - func dropPrefix(_ s: String, _ prefix: String) -> String { - s.hasPrefix(prefix) ? String(s.dropFirst(prefix.count)) : s - } - - func dropImagePrefix(_ s: String) -> String { - dropPrefix(dropPrefix(s, "data:image/png;base64,"), "data:image/jpg;base64,") - } } struct ProfileImage_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/NewChat/AddContactView.swift b/apps/ios/Shared/Views/NewChat/AddContactView.swift index 3c924697ec..9101e893f8 100644 --- a/apps/ios/Shared/Views/NewChat/AddContactView.swift +++ b/apps/ios/Shared/Views/NewChat/AddContactView.swift @@ -22,9 +22,7 @@ struct AddContactView: View { .multilineTextAlignment(.center) QRCode(uri: connReqInvitation) .padding() - (Text("If you cannot meet in person, you can ") + - Text("scan QR code in the video call").bold() + - Text(", or you can share the invitation link via any other channel.")) + Text("If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel.") .font(.subheadline) .multilineTextAlignment(.center) .padding(.horizontal) diff --git a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift index 2513801fcc..9944dc21fb 100644 --- a/apps/ios/Shared/Views/NewChat/ConnectContactView.swift +++ b/apps/ios/Shared/Views/NewChat/ConnectContactView.swift @@ -26,7 +26,11 @@ struct ConnectContactView: View { .aspectRatio(1, contentMode: .fit) .border(.gray) } - .padding(13.0) + .padding(12) + Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.") + .font(.subheadline) + .multilineTextAlignment(.center) + .padding(.horizontal) } } diff --git a/apps/ios/Shared/Views/NewChat/CreateGroupView.swift b/apps/ios/Shared/Views/NewChat/CreateGroupView.swift index 89a65f1ecd..54f1d6c206 100644 --- a/apps/ios/Shared/Views/NewChat/CreateGroupView.swift +++ b/apps/ios/Shared/Views/NewChat/CreateGroupView.swift @@ -10,7 +10,7 @@ import SwiftUI struct CreateGroupView: View { var body: some View { - Text("CreateGroupView") + EmptyView() } } diff --git a/apps/ios/Shared/Views/NewChat/NewChatButton.swift b/apps/ios/Shared/Views/NewChat/NewChatButton.swift index 0fff56bf3f..83f7fd85fe 100644 --- a/apps/ios/Shared/Views/NewChat/NewChatButton.swift +++ b/apps/ios/Shared/Views/NewChat/NewChatButton.swift @@ -65,7 +65,7 @@ struct NewChatButton: View { } func connectionErrorAlert(_ error: Error) { - AlertManager.shared.showAlertMsg(title: "Connection error", message: error.localizedDescription) + AlertManager.shared.showAlertMsg(title: "Connection error", message: "Error: \(error.localizedDescription)") } } @@ -75,12 +75,11 @@ enum ConnReqType: Equatable { } func connectionReqSentAlert(_ type: ConnReqType) { - let whenConnected = type == .contact - ? "your connection request is accepted" - : "your contact's device is online" AlertManager.shared.showAlertMsg( title: "Connection request sent!", - message: "You will be connected when \(whenConnected), please wait or check later!" + message: type == .contact + ? "You will be connected when your connection request is accepted, please wait or check later!" + : "You will be connected when your contact's device is online, please wait or check later!" ) } diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index ae9321fd43..9e92eebb4d 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -10,6 +10,8 @@ import SwiftUI private let terminalFont = Font.custom("Menlo", size: 16) +private let maxItemSize: Int = 50000 + struct TerminalView: View { @EnvironmentObject var chatModel: ChatModel @State var inProgress: Bool = false @@ -24,11 +26,18 @@ struct TerminalView: View { LazyVStack { ForEach(chatModel.terminalItems) { item in NavigationLink { + let s = item.details ScrollView { - Text(item.details) - .textSelection(.enabled) + Text(s.prefix(maxItemSize)) .padding() } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { showShareSheet(items: [s]) } label: { + Image(systemName: "square.and.arrow.up") + } + } + } } label: { HStack { Text(item.id.formatted(date: .omitted, time: .standard)) diff --git a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift index 855d0adff1..c5dafc8663 100644 --- a/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift +++ b/apps/ios/Shared/Views/UserSettings/MarkdownHelp.swift @@ -30,9 +30,9 @@ struct MarkdownHelp: View { } } -private func mdFormat(_ format: String, _ example: Text) -> some View { +private func mdFormat(_ format: LocalizedStringKey, _ example: Text) -> some View { HStack { - Text(format).frame(width: 88, alignment: .leading) + Text(format).frame(width: 120, alignment: .leading) example } } diff --git a/apps/ios/Shared/Views/UserSettings/SMPServers.swift b/apps/ios/Shared/Views/UserSettings/SMPServers.swift index 861a934980..a687f613c3 100644 --- a/apps/ios/Shared/Views/UserSettings/SMPServers.swift +++ b/apps/ios/Shared/Views/UserSettings/SMPServers.swift @@ -82,7 +82,7 @@ struct SMPServers: View { saveUserSMPServers() } .alert(isPresented: $showBadServersAlert) { - Alert(title: Text("Error saving SMP servers"), message: Text("Make sure SMP server addresses are in correct format, line separated and are not duplicated")) + Alert(title: Text("Error saving SMP servers"), message: Text("Make sure SMP server addresses are in correct format, line separated and are not duplicated.")) } Spacer() howToButton() diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index feb7ec85fc..933f5f95bd 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -100,7 +100,7 @@ struct SettingsView: View { UIApplication.shared.open(simplexTeamURL) } } label: { - Text("Chat with the founder") + Text("Chat with the developers") } } HStack { diff --git a/apps/ios/Shared/Views/UserSettings/UserAddress.swift b/apps/ios/Shared/Views/UserSettings/UserAddress.swift index e15bd167c1..c1ff708233 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddress.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddress.swift @@ -14,7 +14,7 @@ struct UserAddress: View { var body: some View { VStack (alignment: .leading) { - Text("You can share your address as a link or as a QR code - anybody will be able to connect to you, and if you later delete it - you won't lose your contacts.") + Text("You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it.") .padding(.bottom) if let userAdress = chatModel.userAddress { QRCode(uri: userAdress) diff --git a/apps/ios/Shared/Views/UserSettings/UserProfile.swift b/apps/ios/Shared/Views/UserSettings/UserProfile.swift index 4e5d62bf31..8c38681b3c 100644 --- a/apps/ios/Shared/Views/UserSettings/UserProfile.swift +++ b/apps/ios/Shared/Views/UserSettings/UserProfile.swift @@ -14,9 +14,8 @@ struct UserProfile: View { @State private var editProfile = false @State private var showChooseSource = false @State private var showImagePicker = false - @State private var imageSource: UIImagePickerController.SourceType = .photoLibrary - @State private var pickedImage: UIImage? = nil - @State private var tmpImageUrl: URL? = nil + @State private var imageSource: ImageSource = .imageLibrary + @State private var chosenImage: UIImage? = nil var body: some View { let user: User = chatModel.currentUser! @@ -84,29 +83,23 @@ struct UserProfile: View { showImagePicker = true } Button("Choose from library") { - imageSource = .photoLibrary + imageSource = .imageLibrary showImagePicker = true } } .sheet(isPresented: $showImagePicker) { - ImagePicker(source: imageSource, image: $pickedImage, imageUrl: $tmpImageUrl) + switch imageSource { + case .imageLibrary: + LibraryImagePicker(image: $chosenImage) { + didSelectItem in showImagePicker = false + } + case .camera: + CameraImagePicker(image: $chosenImage) + } } - .onChange(of: pickedImage) { image in - if let image = image, - let data = resizeToSquare(image, 104).jpegData(compressionQuality: 0.85) { - let imageStr = "data:image/jpg;base64,\(data.base64EncodedString())" - if imageStr.count <= 12500 { - profile.image = imageStr - } else { - logger.error("UserProfile: resized image is too big \(imageStr.count)") - } - if let tmpImageUrl = tmpImageUrl { - do { - try FileManager.default.removeItem(at: tmpImageUrl) - } catch { - logger.error("UserProfile: file deletion error \(error.localizedDescription)") - } - } + .onChange(of: chosenImage) { image in + if let image = image { + profile.image = resizeImageToDataSize(cropToSquare(image), maxDataSize: 12500) } else { profile.image = nil } @@ -168,30 +161,6 @@ struct UserProfile: View { } } -func resize(_ image: UIImage, to newSize: CGSize) -> UIImage { - let format = UIGraphicsImageRendererFormat() - format.scale = 1.0 - format.opaque = true - return UIGraphicsImageRenderer(bounds: CGRect(origin: .zero, size: newSize), format: format).image { _ in - let size = image.size - let hScale = newSize.height / size.height - let vScale = newSize.width / size.width - let scale = max(hScale, vScale) // scaleToFill - let resizeSize = CGSize(width: size.width * scale, height: size.height * scale) - var middle = CGPoint.zero - if resizeSize.width > newSize.width { - middle.x -= (resizeSize.width - newSize.width) / 2 - } else if resizeSize.height > newSize.height { - middle.y -= (resizeSize.height - newSize.height) / 2 - } - image.draw(in: CGRect(origin: middle, size: resizeSize)) - } -} - -func resizeToSquare(_ image: UIImage, _ side: CGFloat) -> UIImage { - resize(image, to: CGSize(width: side, height: side)) -} - struct UserProfile_Previews: PreviewProvider { static var previews: some View { let chatModel1 = ChatModel() diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..d0ffdd59b0 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "locale" : "en" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff new file mode 100644 index 0000000000..6cea986b34 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Localized Contents/en.xliff @@ -0,0 +1,801 @@ + + + +
+ +
+ + + + + No comment provided by engineer. + + + ( + ( + No comment provided by engineer. + + + (can be copied) + (can be copied) + No comment provided by engineer. + + + !1 colored! + !1 colored! + No comment provided by engineer. + + + #secret# + #secret# + No comment provided by engineer. + + + %@ is connected! + %@ is connected! + notification title + + + %@ wants to connect! + %@ wants to connect! + notification title + + + %lld + %lld + No comment provided by engineer. + + + %lldk + %lldk + No comment provided by engineer. + + + (shared only with your contacts) + (shared only with your contacts) + No comment provided by engineer. + + + ) + ) + No comment provided by engineer. + + + **Add new contact**: to create your one-time QR Code for your contact. + **Add new contact**: to create your one-time QR Code for your contact. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact who shows QR code to you. + **Scan QR code**: to connect to your contact who shows QR code to you. + No comment provided by engineer. + + + *bold* + *bold* + No comment provided by engineer. + + + , + , + No comment provided by engineer. + + + 6 + 6 + No comment provided by engineer. + + + : + : + No comment provided by engineer. + + + : %@ + : %@ + No comment provided by engineer. + + + Accept + Accept + accept contact request via notification + + + Accept contact + Accept contact + No comment provided by engineer. + + + Accept contact request from %@? + Accept contact request from %@? + notification body + + + Add contact + Add contact + No comment provided by engineer. + + + All your contacts will remain connected + All your contacts will remain connected + No comment provided by engineer. + + + Cancel + Cancel + No comment provided by engineer. + + + Chat console + Chat console + No comment provided by engineer. + + + Chat with the developers + Chat with the developers + No comment provided by engineer. + + + Chats + Chats + back button to return to chats list + + + Choose from library + Choose from library + No comment provided by engineer. + + + Configure SMP servers + Configure SMP servers + No comment provided by engineer. + + + Confirm + Confirm + No comment provided by engineer. + + + Connect + Connect + No comment provided by engineer. + + + Connect via contact link? + Connect via contact link? + No comment provided by engineer. + + + Connect via invitation link? + Connect via invitation link? + No comment provided by engineer. + + + Connecting server… + Connecting server… + No comment provided by engineer. + + + Connecting server… (error: %@) + Connecting server… (error: %@) + No comment provided by engineer. + + + Connecting... + Connecting... + No comment provided by engineer. + + + Connection error + Connection error + No comment provided by engineer. + + + Connection request + Connection request + No comment provided by engineer. + + + Connection request sent! + Connection request sent! + No comment provided by engineer. + + + Connection timeout + Connection timeout + No comment provided by engineer. + + + Contact already exists + Contact already exists + No comment provided by engineer. + + + Contact and all messages will be deleted - this cannot be undone! + Contact and all messages will be deleted - this cannot be undone! + No comment provided by engineer. + + + Contact is connected + Contact is connected + notification + + + Copy + Copy + No comment provided by engineer. + + + Create + Create + No comment provided by engineer. + + + Create address + Create address + No comment provided by engineer. + + + Create group + Create group + No comment provided by engineer. + + + Create profile + Create profile + No comment provided by engineer. + + + Delete + Delete + No comment provided by engineer. + + + Delete address + Delete address + No comment provided by engineer. + + + Delete address? + Delete address? + No comment provided by engineer. + + + Delete contact + Delete contact + No comment provided by engineer. + + + Delete contact? + Delete contact? + No comment provided by engineer. + + + Delete for me + Delete for me + No comment provided by engineer. + + + Delete group + Delete group + No comment provided by engineer. + + + Delete message? + Delete message? + No comment provided by engineer. + + + Develop + Develop + No comment provided by engineer. + + + Display name + Display name + No comment provided by engineer. + + + Edit + Edit + No comment provided by engineer. + + + Enter one SMP server per line: + Enter one SMP server per line: + No comment provided by engineer. + + + Error saving SMP servers + Error saving SMP servers + No comment provided by engineer. + + + Error: %@ + Error: %@ + No comment provided by engineer. + + + Error: URL is invalid + Error: URL is invalid + No comment provided by engineer. + + + Full name (optional) + Full name (optional) + No comment provided by engineer. + + + Group deletion is not supported + Group deletion is not supported + No comment provided by engineer. + + + Help + Help + No comment provided by engineer. + + + How to + How to + No comment provided by engineer. + + + How to use SimpleX Chat + How to use SimpleX Chat + No comment provided by engineer. + + + How to use markdown + How to use markdown + No comment provided by engineer. + + + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + No comment provided by engineer. + + + If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. + If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. + No comment provided by engineer. + + + If you received SimpleX Chat invitation link you can open it in your browser: + If you received SimpleX Chat invitation link you can open it in your browser: + No comment provided by engineer. + + + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + No comment provided by engineer. + + + Invalid connection link + Invalid connection link + No comment provided by engineer. + + + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + No comment provided by engineer. + + + Markdown in messages + Markdown in messages + No comment provided by engineer. + + + Message delivery error + Message delivery error + No comment provided by engineer. + + + Most likely this contact has deleted the connection with you. + Most likely this contact has deleted the connection with you. + No comment provided by engineer. + + + New contact request + New contact request + notification + + + New message + New message + notifications + + + Notifications are disabled! + Notifications are disabled! + No comment provided by engineer. + + + Open Settings + Open Settings + No comment provided by engineer. + + + Please check that you used the correct link or ask your contact to send you another one. + Please check that you used the correct link or ask your contact to send you another one. + No comment provided by engineer. + + + Please check your network connection and try again. + Please check your network connection and try again. + No comment provided by engineer. + + + Profile image + Profile image + No comment provided by engineer. + + + Read + Read + No comment provided by engineer. + + + Reject + Reject + No comment provided by engineer. + + + Reject contact (sender NOT notified) + Reject contact (sender NOT notified) + No comment provided by engineer. + + + Reject contact request + Reject contact request + No comment provided by engineer. + + + Reply + Reply + No comment provided by engineer. + + + SMP servers + SMP servers + No comment provided by engineer. + + + Save + Save + No comment provided by engineer. + + + Save (and notify contacts) + Save (and notify contacts) + No comment provided by engineer. + + + Saved SMP servers will be removed + Saved SMP servers will be removed + No comment provided by engineer. + + + Scan QR code + Scan QR code + No comment provided by engineer. + + + Server connected + Server connected + No comment provided by engineer. + + + Settings + Settings + No comment provided by engineer. + + + Share + Share + No comment provided by engineer. + + + Share invitation link + Share invitation link + No comment provided by engineer. + + + Share link + Share link + No comment provided by engineer. + + + Show QR code to your contact +to scan from the app + Show QR code to your contact +to scan from the app + No comment provided by engineer. + + + Start new chat + Start new chat + No comment provided by engineer. + + + Take picture + Take picture + No comment provided by engineer. + + + Tap button + Tap button + No comment provided by engineer. + + + Thank you for installing SimpleX Chat! + Thank you for installing SimpleX Chat! + No comment provided by engineer. + + + The app can notify you when you receive messages or contact requests - please open settings to enable. + The app can notify you when you receive messages or contact requests - please open settings to enable. + No comment provided by engineer. + + + The messaging and application platform 100% private by design! + The messaging and application platform 100% private by design! + No comment provided by engineer. + + + The sender will NOT be notified + The sender will NOT be notified + No comment provided by engineer. + + + To ask any questions and to receive updates: + To ask any questions and to receive updates: + No comment provided by engineer. + + + To connect via link + To connect via link + No comment provided by engineer. + + + To start a new chat + To start a new chat + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact (error: %@). + Trying to connect to the server used to receive messages from this contact (error: %@). + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact. + Trying to connect to the server used to receive messages from this contact. + No comment provided by engineer. + + + Unexpected error: %@ + Unexpected error: %@ + No comment provided by engineer. + + + Use SimpleX Chat servers? + Use SimpleX Chat servers? + No comment provided by engineer. + + + Using SimpleX Chat servers. + Using SimpleX Chat servers. + No comment provided by engineer. + + + Welcome %@! + Welcome %@! + No comment provided by engineer. + + + You + You + No comment provided by engineer. + + + You are already connected to %@ via this link. + You are already connected to %@ via this link. + No comment provided by engineer. + + + You are connected to the server used to receive messages from this contact. + You are connected to the server used to receive messages from this contact. + No comment provided by engineer. + + + You can now send messages to %@ + You can now send messages to %@ + notification body + + + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. + No comment provided by engineer. + + + You can use markdown to format messages: + You can use markdown to format messages: + No comment provided by engineer. + + + You control your chat! + You control your chat! + No comment provided by engineer. + + + You will be connected when your connection request is accepted, please wait or check later! + You will be connected when your connection request is accepted, please wait or check later! + No comment provided by engineer. + + + You will be connected when your contact's device is online, please wait or check later! + You will be connected when your contact's device is online, please wait or check later! + No comment provided by engineer. + + + Your SMP servers + Your SMP servers + No comment provided by engineer. + + + Your SimpleX contact address + Your SimpleX contact address + No comment provided by engineer. + + + Your chat address + Your chat address + No comment provided by engineer. + + + Your chat profile + Your chat profile + No comment provided by engineer. + + + Your chat profile will be sent to your contact + Your chat profile will be sent to your contact + No comment provided by engineer. + + + Your chats + Your chats + No comment provided by engineer. + + + Your profile is stored on your device and shared only with your contacts. +SimpleX servers cannot see your profile. + Your profile is stored on your device and shared only with your contacts. +SimpleX servers cannot see your profile. + No comment provided by engineer. + + + Your profile will be sent to the contact that you received this link from + Your profile will be sent to the contact that you received this link from + No comment provided by engineer. + + + Your profile, contacts and messages (once delivered) are only stored locally on your device. + Your profile, contacts and messages (once delivered) are only stored locally on your device. + No comment provided by engineer. + + + Your settings + Your settings + No comment provided by engineer. + + + [Send us email](mailto:chat@simplex.chat) + [Send us email](mailto:chat@simplex.chat) + No comment provided by engineer. + + + _italic_ + _italic_ + No comment provided by engineer. + + + `a + b` + `a + b` + No comment provided by engineer. + + + above, then: + above, then: + No comment provided by engineer. + + + bold + bold + No comment provided by engineer. + + + colored + colored + No comment provided by engineer. + + + connect to SimpleX Chat developers. + connect to SimpleX Chat developers. + No comment provided by engineer. + + + deleted + deleted + deleted chat item + + + italic + italic + No comment provided by engineer. + + + receiving files is not supported yet + receiving files is not supported yet + to be removed + + + secret + secret + No comment provided by engineer. + + + sending files is not supported yet + sending files is not supported yet + to be removed + + + strike + strike + No comment provided by engineer. + + + v%@ (%@) + v%@ (%@) + No comment provided by engineer. + + + wants to connect to you! + wants to connect to you! + No comment provided by engineer. + + + ~strike~ + ~strike~ + No comment provided by engineer. + + + πŸ’» desktop: scan displayed QR code from the app, via **Scan QR code**. + πŸ’» desktop: scan displayed QR code from the app, via **Scan QR code**. + No comment provided by engineer. + + + πŸ“± mobile: tap **Open in mobile app**, then tap **Connect** in the app. + πŸ“± mobile: tap **Open in mobile app**, then tap **Connect** in the app. + No comment provided by engineer. + + +
+ +
+ +
+ + + SimpleX + SimpleX + Bundle name + + + SimpleX needs camera access to scan QR codes to connect to other app users + SimpleX needs camera access to scan QR codes to connect to other app users + Privacy - Camera Usage Description + + +
+
diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..aaa7f79bc8 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 0000000000..8a3092dda6 Binary files /dev/null and b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/Localizable.strings differ diff --git a/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..39dfcd9dc5 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,4 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other app users"; diff --git a/apps/ios/SimpleX Localizations/en.xcloc/contents.json b/apps/ios/SimpleX Localizations/en.xcloc/contents.json new file mode 100644 index 0000000000..3e0830ec05 --- /dev/null +++ b/apps/ios/SimpleX Localizations/en.xcloc/contents.json @@ -0,0 +1,12 @@ +{ + "developmentRegion" : "en", + "project" : "SimpleX.xcodeproj", + "targetLocale" : "en", + "toolInfo" : { + "toolBuildNumber" : "13E113", + "toolID" : "com.apple.dt.xcode", + "toolName" : "Xcode", + "toolVersion" : "13.3" + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..98a1783ee9 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,15 @@ +{ + "colors" : [ + { + "idiom" : "universal", + "locale" : "ru" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff new file mode 100644 index 0000000000..ecc33e149c --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Localized Contents/ru.xliff @@ -0,0 +1,800 @@ + + + +
+ +
+ + + + + No comment provided by engineer. + + + ( + ( + No comment provided by engineer. + + + (can be copied) + (ΠΌΠΎΠΆΠ½ΠΎ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ) + No comment provided by engineer. + + + !1 colored! + !1 Ρ†Π²Π΅Ρ‚! + No comment provided by engineer. + + + #secret# + #сСкрСт# + No comment provided by engineer. + + + %@ is connected! + УстановлСно соСдинСниС с %@! + notification title + + + %@ wants to connect! + %@ Ρ…ΠΎΡ‡Π΅Ρ‚ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ! + notification title + + + %lld + %lld + No comment provided by engineer. + + + %lldk + %lldk + No comment provided by engineer. + + + (shared only with your contacts) + (отправляСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°ΠΌ) + No comment provided by engineer. + + + ) + ) + No comment provided by engineer. + + + **Add new contact**: to create your one-time QR Code for your contact. + **Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚**: Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΎΠ΄Π½ΠΎΡ€Π°Π·ΠΎΠ²Ρ‹ΠΉ QR ΠΊΠΎΠ΄ ΠΈΠ»ΠΈ ссылку для вашСго ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°. + No comment provided by engineer. + + + **Scan QR code**: to connect to your contact who shows QR code to you. + **Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄**: Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ΠΎΠΌ (ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Π²Π°ΠΌ QR ΠΊΠΎΠ΄). + No comment provided by engineer. + + + *bold* + \*ΠΆΠΈΡ€Π½Ρ‹ΠΉ* + No comment provided by engineer. + + + , + , + No comment provided by engineer. + + + 6 + 6 + No comment provided by engineer. + + + : + : + No comment provided by engineer. + + + : %@ + : %@ + No comment provided by engineer. + + + Accept + ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ + accept contact request via notification + + + Accept contact + ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ запрос + No comment provided by engineer. + + + Accept contact request from %@? + ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ запрос Π½Π° соСдинСниС ΠΎΡ‚ %@? + notification body + + + Add contact + Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ + No comment provided by engineer. + + + All your contacts will remain connected + ВсС ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ соСдинились Ρ‡Π΅Ρ€Π΅Π· этот адрСс, сохранятся. + No comment provided by engineer. + + + Cancel + ΠžΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ + No comment provided by engineer. + + + Chat console + Консоль + No comment provided by engineer. + + + Chat with the developers + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ°ΠΌΠΈ + No comment provided by engineer. + + + Chats + Назад + back button to return to chats list + + + Choose from library + Π’Ρ‹Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΠΈ + No comment provided by engineer. + + + Configure SMP servers + Настройка SMP сСрвСров + No comment provided by engineer. + + + Confirm + ΠŸΠΎΠ΄Ρ‚Π²Π΅Ρ€Π΄ΠΈΡ‚ΡŒ + No comment provided by engineer. + + + Connect + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ + No comment provided by engineer. + + + Connect via contact link? + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку-ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚? + No comment provided by engineer. + + + Connect via invitation link? + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку-ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅? + No comment provided by engineer. + + + Connecting server… + УстанавливаСтся соСдинСниС с сСрвСром… + No comment provided by engineer. + + + Connecting server… (error: %@) + УстанавливаСтся соСдинСниС с сСрвСром… (ошибка: %@) + No comment provided by engineer. + + + Connecting... + УстанавливаСтся соСдинСниС… + No comment provided by engineer. + + + Connection error + Ошибка соСдинСния + No comment provided by engineer. + + + Connection request + Запрос Π½Π° соСдинСниС + No comment provided by engineer. + + + Connection request sent! + Запрос Π½Π° соСдинСниС ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½! + No comment provided by engineer. + + + Connection timeout + ΠŸΡ€Π΅Π²Ρ‹ΡˆΠ΅Π½ΠΎ врСмя соСдинСния + No comment provided by engineer. + + + Contact already exists + Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ + No comment provided by engineer. + + + Contact and all messages will be deleted - this cannot be undone! + ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΈ всС сообщСния Π±ΡƒΠ΄ΡƒΡ‚ ΡƒΠ΄Π°Π»Π΅Π½Ρ‹ - это дСйствиС нСльзя ΠΎΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ! + No comment provided by engineer. + + + Contact is connected + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ с ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ΠΎΠΌ установлСно + notification + + + Copy + Π‘ΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + No comment provided by engineer. + + + Create + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ + No comment provided by engineer. + + + Create address + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ адрСс + No comment provided by engineer. + + + Create group + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π³Ρ€ΡƒΠΏΠΏΡƒ + No comment provided by engineer. + + + Create profile + Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ + No comment provided by engineer. + + + Delete + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ + No comment provided by engineer. + + + Delete address + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ адрСс + No comment provided by engineer. + + + Delete address? + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ адрСс? + No comment provided by engineer. + + + Delete contact + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ + No comment provided by engineer. + + + Delete contact? + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚? + No comment provided by engineer. + + + Delete for me + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ для мСня + No comment provided by engineer. + + + Delete group + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ Π³Ρ€ΡƒΠΏΠΏΡƒ + No comment provided by engineer. + + + Delete message? + Π£Π΄Π°Π»ΠΈΡ‚ΡŒ сообщСниС? + No comment provided by engineer. + + + Develop + Для Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠΎΠ² + No comment provided by engineer. + + + Display name + Имя профиля + No comment provided by engineer. + + + Edit + Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + No comment provided by engineer. + + + Enter one SMP server per line: + Π’Π²Π΅Π΄ΠΈΡ‚Π΅ SMP сСрвСры, ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ Π½Π° ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ строкС: + No comment provided by engineer. + + + Error saving SMP servers + Ошибка ΠΏΡ€ΠΈ сохранСнии SMP сСрвСров + No comment provided by engineer. + + + Error: %@ + Ошибка: %@ + No comment provided by engineer. + + + Error: URL is invalid + Ошибка: нСвСрная ссылка + No comment provided by engineer. + + + Full name (optional) + ПолноС имя (Π½Π΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ) + No comment provided by engineer. + + + Group deletion is not supported + Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ Π³Ρ€ΡƒΠΏΠΏ Π½Π΅ поддСрТиваСтся + No comment provided by engineer. + + + Help + ΠŸΠΎΠΌΠΎΡ‰ΡŒ + No comment provided by engineer. + + + How to + Π˜Π½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡ + No comment provided by engineer. + + + How to use SimpleX Chat + Как ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ SimpleX Chat + No comment provided by engineer. + + + How to use markdown + Как Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + No comment provided by engineer. + + + If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link. + Если Π²Ρ‹ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Π²ΡΡ‚Ρ€Π΅Ρ‚ΠΈΡ‚ΡŒΡΡ Π»ΠΈΡ‡Π½ΠΎ, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ **ΡΠΎΡΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ Π²ΠΎ врСмя Π²ΠΈΠ΄Π΅ΠΎΠ·Π²ΠΎΠ½ΠΊΠ°**, ΠΈΠ»ΠΈ ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π²Π°ΠΌ ссылку. + No comment provided by engineer. + + + If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel. + Если Π²Ρ‹ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Π²ΡΡ‚Ρ€Π΅Ρ‚ΠΈΡ‚ΡŒΡΡ Π»ΠΈΡ‡Π½ΠΎ, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ **ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ Π²ΠΎ врСмя Π²ΠΈΠ΄Π΅ΠΎΠ·Π²ΠΎΠ½ΠΊΠ°** ΠΈΠ»ΠΈ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ ссылку Ρ‡Π΅Ρ€Π΅Π· любой Π΄Ρ€ΡƒΠ³ΠΎΠΉ ΠΊΠ°Π½Π°Π» связи. + No comment provided by engineer. + + + If you received SimpleX Chat invitation link you can open it in your browser: + Если Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΠ»ΠΈ ссылку с ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ΠΌ ΠΈΠ· SimpleX Chat, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π΅Ρ‘ Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π΅: + No comment provided by engineer. + + + Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat) + [SimpleX Chat для Ρ‚Π΅Ρ€ΠΌΠΈΠ½Π°Π»Π°](https://github.com/simplex-chat/simplex-chat) + No comment provided by engineer. + + + Invalid connection link + Ошибка Π² ссылкС ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π° + No comment provided by engineer. + + + Make sure SMP server addresses are in correct format, line separated and are not duplicated. + ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅, Ρ‡Ρ‚ΠΎ адрСса SMP сСрвСров ΠΈΠΌΠ΅ΡŽΡ‚ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚, ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ адрСс Π½Π° ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ строкС ΠΈ Π½Π΅ повторяСтся. + No comment provided by engineer. + + + Markdown in messages + Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ сообщСний + No comment provided by engineer. + + + Message delivery error + Ошибка доставки сообщСния + No comment provided by engineer. + + + Most likely this contact has deleted the connection with you. + Π‘ΠΊΠΎΡ€Π΅Π΅ всСго, этот ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΡƒΠ΄Π°Π»ΠΈΠ» соСдинСниС с Π²Π°ΠΌΠΈ. + No comment provided by engineer. + + + New contact request + Новый запрос Π½Π° соСдинСниС + notification + + + New message + НовоС сообщСниС + notifications + + + Notifications are disabled! + УвСдомлСния Π²Ρ‹ΠΊΠ»ΡŽΡ‡Π΅Π½Ρ‹ + No comment provided by engineer. + + + Open Settings + ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Настройки + No comment provided by engineer. + + + Please check that you used the correct link or ask your contact to send you another one. + ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅, Ρ‡Ρ‚ΠΎ Π²Ρ‹ использовали ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½ΡƒΡŽ ссылку ΠΈΠ»ΠΈ попроситС, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΠ» Π²Π°ΠΌ Π΄Ρ€ΡƒΠ³ΡƒΡŽ ссылку. + No comment provided by engineer. + + + Please check your network connection and try again. + ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ вашС соСдинСниС с ΡΠ΅Ρ‚ΡŒΡŽ ΠΈ ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ Π΅Ρ‰Π΅ Ρ€Π°Π·. + No comment provided by engineer. + + + Profile image + Аватар + No comment provided by engineer. + + + Read + ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½ΠΎ + No comment provided by engineer. + + + Reject + ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ + No comment provided by engineer. + + + Reject contact (sender NOT notified) + ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ (Π½Π΅ увСдомляя отправитСля) + No comment provided by engineer. + + + Reject contact request + ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ запрос + No comment provided by engineer. + + + Reply + ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ + No comment provided by engineer. + + + SMP servers + SMP сСрвСры + No comment provided by engineer. + + + Save + Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ + No comment provided by engineer. + + + Save (and notify contacts) + Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ (ΠΈ ΡƒΠ²Π΅Π΄ΠΎΠΌΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹) + No comment provided by engineer. + + + Saved SMP servers will be removed + Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½Π½Ρ‹Π΅ SMP сСрвСры Π±ΡƒΠ΄ΡƒΡ‚ ΡƒΠ΄Π°Π»Π΅Π½Ρ‹ + No comment provided by engineer. + + + Scan QR code + Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ + No comment provided by engineer. + + + Server connected + УстановлСно соСдинСниС с сСрвСром + No comment provided by engineer. + + + Settings + Настройки + No comment provided by engineer. + + + Share + ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ + No comment provided by engineer. + + + Share invitation link + ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ ссылкой + No comment provided by engineer. + + + Share link + ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ ссылкой + No comment provided by engineer. + + + Show QR code to your contact +to scan from the app + ΠŸΠΎΠΊΠ°ΠΆΠΈΡ‚Π΅ QR ΠΊΠΎΠ΄ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ для сканирования Π² ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΈ + No comment provided by engineer. + + + Start new chat + ΠΠ°Ρ‡Π°Ρ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ Ρ€Π°Π·Π³ΠΎΠ²ΠΎΡ€ + No comment provided by engineer. + + + Take picture + Π‘Π΄Π΅Π»Π°Ρ‚ΡŒ Ρ„ΠΎΡ‚ΠΎ + No comment provided by engineer. + + + Tap button + НаТмитС ΠΊΠ½ΠΎΠΏΠΊΡƒ + No comment provided by engineer. + + + Thank you for installing SimpleX Chat! + Бпасибо, Ρ‡Ρ‚ΠΎ Π’Ρ‹ установили SimpleX Chat! + No comment provided by engineer. + + + The app can notify you when you receive messages or contact requests - please open settings to enable. + ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠΎΡΡ‹Π»Π°Ρ‚ΡŒ Π²Π°ΠΌ увСдомлСния ΠΎ сообщСниях ΠΈ запросах Π½Π° соСдинСниС - увСдомлСния ΠΌΠΎΠΆΠ½ΠΎ Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Π² Настройках. + No comment provided by engineer. + + + The messaging and application platform 100% private by design! + ΠŸΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΠ° для сообщСний ΠΈ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΉ, которая Π·Π°Ρ‰ΠΈΡ‰Π°Π΅Ρ‚ Π²Π°ΡˆΡƒ Π»ΠΈΡ‡Π½ΡƒΡŽ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ ΠΈ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡ‚ΡŒ. + No comment provided by engineer. + + + The sender will NOT be notified + ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚Π΅Π»ΡŒ Π½Π΅ Π±ΡƒΠ΄Π΅Ρ‚ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Ρ‘Π½ + No comment provided by engineer. + + + To ask any questions and to receive updates: + Π—Π°Π΄Π°Ρ‚ΡŒ вопросы ΠΈ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ увСдомлСния ΠΎ Π½ΠΎΠ²Ρ‹Ρ… вСрсиях: + No comment provided by engineer. + + + To connect via link + Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку + No comment provided by engineer. + + + To start a new chat + ΠΠ°Ρ‡Π°Ρ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ Ρ€Π°Π·Π³ΠΎΠ²ΠΎΡ€ + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact (error: %@). + УстанавливаСтся соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π° (ошибка: %@). + No comment provided by engineer. + + + Trying to connect to the server used to receive messages from this contact. + УстанавливаСтся соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°. + No comment provided by engineer. + + + Unexpected error: %@ + НСоТиданная ошибка: %@ + No comment provided by engineer. + + + Use SimpleX Chat servers? + Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ сСрвСры прСдосталСнныС SimpleX Chat? + No comment provided by engineer. + + + Using SimpleX Chat servers. + Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΡŽΡ‚ΡΡ сСрвСры, прСдоставлСнныС SimpleX Chat. + No comment provided by engineer. + + + Welcome %@! + ЗдравствуйтС %@! + No comment provided by engineer. + + + You + Π’Ρ‹ + No comment provided by engineer. + + + You are already connected to %@ via this link. + Π’Ρ‹ ΡƒΠΆΠ΅ соСдинСны с %@ Ρ‡Π΅Ρ€Π΅Π· эту ссылку. + No comment provided by engineer. + + + You are connected to the server used to receive messages from this contact. + УстановлСно соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ получаСтся сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°. + No comment provided by engineer. + + + You can now send messages to %@ + Π’Ρ‹ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ сообщСния %@ + notification body + + + You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it. + Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ ваш адрСс ΠΊΠ°ΠΊ ссылку ΠΈΠ»ΠΈ ΠΊΠ°ΠΊ QR ΠΊΠΎΠ΄ - ΠΊΡ‚ΠΎ ΡƒΠ³ΠΎΠ΄Π½ΠΎ смоТСт ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Π²Π°ΠΌΠΈ. Π’Ρ‹ смоТСтС ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ адрСс, сохранив ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ Ρ‡Π΅Ρ€Π΅Π· Π½Π΅Π³ΠΎ соСдинились. + No comment provided by engineer. + + + You can use markdown to format messages: + Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ сообщСния: + No comment provided by engineer. + + + You control your chat! + Π’Ρ‹ ΠΊΠΎΡ‚Ρ€ΠΎΠ»ΠΈΡ€ΡƒΠ΅Ρ‚Π΅ Π’Π°Ρˆ Ρ‡Π°Ρ‚! + No comment provided by engineer. + + + You will be connected when your connection request is accepted, please wait or check later! + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ установлСно, ΠΊΠΎΠ³Π΄Π° ваш запрос Π±ΡƒΠ΄Π΅Ρ‚ принят. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅! + No comment provided by engineer. + + + You will be connected when your contact's device is online, please wait or check later! + Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ установлСно, ΠΊΠΎΠ³Π΄Π° ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΠ½Π»Π°ΠΉΠ½. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅! + No comment provided by engineer. + + + Your SMP servers + Π’Π°ΡˆΠΈ SMP сСрвСры + No comment provided by engineer. + + + Your SimpleX contact address + Π’Π°Ρˆ SimpleX адрСс + No comment provided by engineer. + + + Your chat address + Π’Π°Ρˆ SimpleX адрСс + No comment provided by engineer. + + + Your chat profile + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ + No comment provided by engineer. + + + Your chat profile will be sent to your contact + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ + No comment provided by engineer. + + + Your chats + Π’Π°ΡˆΠΈ Ρ‡Π°Ρ‚Ρ‹ + No comment provided by engineer. + + + Your profile is stored on your device and shared only with your contacts. +SimpleX servers cannot see your profile. + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ хранится Π½Π° вашСм устройствС ΠΈ отправляСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°ΠΌ. +SimpleX сСрвСры Π½Π΅ ΠΌΠΎΠ³ΡƒΡ‚ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ доступ ΠΊ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŽ. + No comment provided by engineer. + + + Your profile will be sent to the contact that you received this link from + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ, ΠΎΡ‚ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ³ΠΎ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΠ»ΠΈ эту ссылку. + No comment provided by engineer. + + + Your profile, contacts and messages (once delivered) are only stored locally on your device. + Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ, ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ ΠΈ сообщСния (послС доставки) хранятся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½Π° вашСм устройствС. + No comment provided by engineer. + + + Your settings + Настройки + No comment provided by engineer. + + + [Send us email](mailto:chat@simplex.chat) + [ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ email](mailto:chat@simplex.chat) + No comment provided by engineer. + + + _italic_ + \_курсив_ + No comment provided by engineer. + + + `a + b` + \`a + b` + No comment provided by engineer. + + + above, then: + Π½Π°Π²Π΅Ρ€Ρ…Ρƒ, Π·Π°Ρ‚Π΅ΠΌ: + No comment provided by engineer. + + + bold + ΠΆΠΈΡ€Π½Ρ‹ΠΉ + No comment provided by engineer. + + + colored + Ρ†Π²Π΅Ρ‚ + No comment provided by engineer. + + + connect to SimpleX Chat developers. + ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ°ΠΌΠΈ. + No comment provided by engineer. + + + deleted + ΡƒΠ΄Π°Π»Π΅Π½ΠΎ + deleted chat item + + + italic + курсив + No comment provided by engineer. + + + receiving files is not supported yet + ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ Ρ„Π°ΠΉΠ»ΠΎΠ² Π½Π΅ поддСрТиваСтся + to be removed + + + secret + сСкрСт + No comment provided by engineer. + + + sending files is not supported yet + ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ° Ρ„Π°ΠΉΠ»ΠΎΠ² Π½Π΅ поддСрТиваСтся + to be removed + + + strike + Π·Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚ΡŒ + No comment provided by engineer. + + + v%@ (%@) + v%@ (%@) + No comment provided by engineer. + + + wants to connect to you! + Ρ…ΠΎΡ‡Π΅Ρ‚ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Π²Π°ΠΌΠΈ! + No comment provided by engineer. + + + ~strike~ + \~Π·Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚ΡŒ~ + No comment provided by engineer. + + + πŸ’» desktop: scan displayed QR code from the app, via **Scan QR code**. + πŸ’» Π½Π° ΠΊΠΎΠΌΠΏΡŒΡŽΡ‚Π΅Ρ€Π΅: сосканируйтС QR ΠΊΠΎΠ΄ ΠΈΠ· прилоТСния Ρ‡Π΅Ρ€Π΅Π· **Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄**. + No comment provided by engineer. + + + πŸ“± mobile: tap **Open in mobile app**, then tap **Connect** in the app. + πŸ“± Π½Π° мобильном: Π½Π°ΠΌΠΆΠΈΡ‚Π΅ ΠΊΠ½ΠΎΠΏΠΊΡƒ **Open in mobile app** Π½Π° Π²Π΅Π± страницС, Π·Π°Ρ‚Π΅ΠΌ Π½Π°ΠΆΠΌΠΈΡ‚Π΅ **Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ** Π² ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΈ. + No comment provided by engineer. + + +
+ +
+ +
+ + + SimpleX + SimpleX + Bundle name + + + SimpleX needs camera access to scan QR codes to connect to other app users + SimpleX ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ для сканирования QR ΠΊΠΎΠ΄ΠΎΠ² ΠΏΡ€ΠΈ соСдинСнии с Π΄Ρ€ΡƒΠ³ΠΈΠΌΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌΠΈ + Privacy - Camera Usage Description + + +
+
diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000000..aaa7f79bc8 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,23 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "red" : "0.000", + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.533" + } + }, + "idiom" : "universal" + } + ], + "properties" : { + "localizable" : true + }, + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/Shared/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings new file mode 100644 index 0000000000..8a3092dda6 Binary files /dev/null and b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/Localizable.strings differ diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..39dfcd9dc5 --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/Source Contents/en.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,4 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX needs camera access to scan QR codes to connect to other app users"; diff --git a/apps/ios/SimpleX Localizations/ru.xcloc/contents.json b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json new file mode 100644 index 0000000000..50ec87db4c --- /dev/null +++ b/apps/ios/SimpleX Localizations/ru.xcloc/contents.json @@ -0,0 +1,12 @@ +{ + "developmentRegion" : "en", + "project" : "SimpleX.xcodeproj", + "targetLocale" : "ru", + "toolInfo" : { + "toolBuildNumber" : "13E113", + "toolID" : "com.apple.dt.xcode", + "toolName" : "Xcode", + "toolVersion" : "13.3" + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/apps/ios/SimpleX--macOS--Info.plist b/apps/ios/SimpleX--macOS--Info.plist deleted file mode 100644 index 0c67376eba..0000000000 --- a/apps/ios/SimpleX--macOS--Info.plist +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 537fe74814..2f299cf329 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -7,127 +7,66 @@ objects = { /* Begin PBXBuildFile section */ + 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; + 3CDBCF4827FF621E00354CDD /* ChatItemLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */; }; 5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; - 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; }; 5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; - 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; }; 5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; - 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; }; 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; - 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; }; 5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; - 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; }; 5C2E260F27A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; - 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260E27A30FDC00F70299 /* ChatView.swift */; }; 5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; - 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E261127A30FEA00F70299 /* TerminalView.swift */; }; 5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; - 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; }; 5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; - 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; }; - 5C36026827F44386009F19D9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026327F44385009F19D9 /* libffi.a */; }; - 5C36026927F44386009F19D9 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026327F44385009F19D9 /* libffi.a */; }; - 5C36026A27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026427F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */; }; - 5C36026B27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026427F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */; }; - 5C36026C27F44386009F19D9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026527F44386009F19D9 /* libgmpxx.a */; }; - 5C36026D27F44386009F19D9 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026527F44386009F19D9 /* libgmpxx.a */; }; - 5C36026E27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026627F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */; }; - 5C36026F27F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026627F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */; }; - 5C36027027F44386009F19D9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026727F44386009F19D9 /* libgmp.a */; }; - 5C36027127F44386009F19D9 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C36026727F44386009F19D9 /* libgmp.a */; }; 5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; - 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; }; 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; - 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; }; + 5C411598280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */; }; + 5C41159A280048E90054D6CB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411594280048E90054D6CB /* libffi.a */; }; + 5C41159C280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */; }; + 5C41159E280048E90054D6CB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411596280048E90054D6CB /* libgmpxx.a */; }; + 5C4115A0280048E90054D6CB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C411597280048E90054D6CB /* libgmp.a */; }; 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; - 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; - 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C5F2B6D27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; - 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */; }; 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; - 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */; }; 5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; - 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C6AD81227A834E300348BD7 /* NewChatButton.swift */; }; 5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; - 5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; }; 5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; - 5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; }; 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; - 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; }; 5C764E80279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; - 5C764E81279C7276000C6508 /* dummy.m in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E7F279C7276000C6508 /* dummy.m */; }; 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; - 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7B279C71D4000C6508 /* libiconv.tbd */; }; - 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; }; 5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; - 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; }; 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; }; 5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; - 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; }; 5C971E2127AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; - 5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */; }; 5C9FD96B27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; - 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96A27A56D4D0075386C /* JSON.swift */; }; 5C9FD96E27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; - 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */; }; 5CA059DC279559F40002BEB4 /* Tests_iOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */; }; 5CA059DE279559F40002BEB4 /* Tests_iOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */; }; - 5CA059E8279559F40002BEB4 /* Tests_macOS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */; }; - 5CA059EA279559F40002BEB4 /* Tests_macOSLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */; }; 5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; - 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C3279559F40002BEB4 /* SimpleXApp.swift */; }; 5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; - 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA059C4279559F40002BEB4 /* ContentView.swift */; }; 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; - 5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5CA059C5279559F40002BEB4 /* Assets.xcassets */; }; 5CA05A4C27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; - 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */; }; - 5CA14D2327F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */; }; - 5CA14D2427F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */; }; - 5CA14D2527F6DE37009B11CE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1F27F6DE37009B11CE /* libgmp.a */; }; - 5CA14D2627F6DE37009B11CE /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D1F27F6DE37009B11CE /* libgmp.a */; }; - 5CA14D2727F6DE37009B11CE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2027F6DE37009B11CE /* libgmpxx.a */; }; - 5CA14D2827F6DE37009B11CE /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2027F6DE37009B11CE /* libgmpxx.a */; }; - 5CA14D2927F6DE37009B11CE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2127F6DE37009B11CE /* libffi.a */; }; - 5CA14D2A27F6DE37009B11CE /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2127F6DE37009B11CE /* libffi.a */; }; - 5CA14D2B27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */; }; - 5CA14D2C27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */; }; 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; - 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D327A853F100ACCCDD /* SettingsButton.swift */; }; 5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; - 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924D627A8563F00ACCCDD /* SettingsView.swift */; }; 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; - 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E027A867BA00ACCCDD /* UserProfile.swift */; }; 5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; - 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB924E327A8683A00ACCCDD /* UserAddress.swift */; }; 5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; - 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */; }; 5CC1C99227A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; - 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */; }; 5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */; }; - 5CC1C99627A6CF7F000D9FF6 /* 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 */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; - 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; - 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */; }; 5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; - 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; }; 5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; - 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; }; 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; - 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; }; 5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; - 5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; }; 5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; - 5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; - 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; 64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; - 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; }; 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; - 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -138,16 +77,11 @@ remoteGlobalIDString = 5CA059C9279559F40002BEB4; remoteInfo = "SimpleX (iOS)"; }; - 5CA059E4279559F40002BEB4 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5CA059BE279559F40002BEB4 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5CA059CF279559F40002BEB4; - remoteInfo = "SimpleX (macOS)"; - }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = ""; }; + 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemLinkView.swift; sourceTree = ""; }; 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = ""; }; 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = ""; }; 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = ""; }; @@ -157,13 +91,13 @@ 5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; 5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = ""; }; 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = ""; }; - 5C36026327F44385009F19D9 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5C36026427F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou.a"; sourceTree = ""; }; - 5C36026527F44386009F19D9 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5C36026627F44386009F19D9 /* libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.0-35IBkEJuAyg38MasSQs4Ou-ghc8.10.7.a"; sourceTree = ""; }; - 5C36026727F44386009F19D9 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = ""; }; 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = ""; }; + 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a"; sourceTree = ""; }; + 5C411594280048E90054D6CB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; + 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a"; sourceTree = ""; }; + 5C411596280048E90054D6CB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; + 5C411597280048E90054D6CB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = ""; }; 5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = ""; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = ""; }; @@ -176,7 +110,6 @@ 5C764E7B279C71D4000C6508 /* libiconv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libiconv.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libiconv.tbd; sourceTree = DEVELOPER_DIR; }; 5C764E7C279C71DB000C6508 /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS15.2.sdk/usr/lib/libz.tbd; sourceTree = DEVELOPER_DIR; }; 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (iOS)-Bridging-Header.h"; sourceTree = ""; }; - 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (macOS)-Bridging-Header.h"; sourceTree = ""; }; 5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = ""; }; 5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = ""; }; 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = ""; }; @@ -187,19 +120,10 @@ 5CA059C4279559F40002BEB4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 5CA059C5279559F40002BEB4 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5CA059CA279559F40002BEB4 /* SimpleX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleX.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 5CA059D0279559F40002BEB4 /* SimpleX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SimpleX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests iOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CA059DB279559F40002BEB4 /* Tests_iOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOS.swift; sourceTree = ""; }; 5CA059DD279559F40002BEB4 /* Tests_iOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_iOSLaunchTests.swift; sourceTree = ""; }; - 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Tests macOS.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; - 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOS.swift; sourceTree = ""; }; - 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests_macOSLaunchTests.swift; sourceTree = ""; }; 5CA05A4B27974EB60002BEB4 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; - 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a"; sourceTree = ""; }; - 5CA14D1F27F6DE37009B11CE /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = ""; }; - 5CA14D2027F6DE37009B11CE /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = ""; }; - 5CA14D2127F6DE37009B11CE /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = ""; }; - 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a"; sourceTree = ""; }; 5CB924D327A853F100ACCCDD /* SettingsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsButton.swift; sourceTree = ""; }; 5CB924D627A8563F00ACCCDD /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5CB924E027A867BA00ACCCDD /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; @@ -207,6 +131,8 @@ 5CB9250C27A9432000ACCCDD /* ChatListNavLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListNavLink.swift; sourceTree = ""; }; 5CC1C99127A6C7F5000D9FF6 /* QRCode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QRCode.swift; sourceTree = ""; }; 5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = ""; }; + 5CC2C0FB2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; + 5CC2C0FE2809BF11000C35E3 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = "ru.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = ""; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = ""; }; 5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = ""; }; 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = ""; }; @@ -224,28 +150,14 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 5CA14D2727F6DE37009B11CE /* libgmpxx.a in Frameworks */, 5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */, - 5CA14D2527F6DE37009B11CE /* libgmp.a in Frameworks */, - 5CA14D2B27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */, + 5C411598280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a in Frameworks */, + 5C4115A0280048E90054D6CB /* libgmp.a in Frameworks */, + 5C41159C280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a in Frameworks */, 5C764E83279C748B000C6508 /* libz.tbd in Frameworks */, - 5CA14D2927F6DE37009B11CE /* libffi.a in Frameworks */, - 5CA14D2327F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */, + 5C41159A280048E90054D6CB /* libffi.a in Frameworks */, 5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 5CA059CD279559F40002BEB4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 5CA14D2C27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a in Frameworks */, - 5CA14D2427F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a in Frameworks */, - 5CA14D2A27F6DE37009B11CE /* libffi.a in Frameworks */, - 5CA14D2827F6DE37009B11CE /* libgmpxx.a in Frameworks */, - 5C764E85279C748C000C6508 /* libz.tbd in Frameworks */, - 5CA14D2627F6DE37009B11CE /* libgmp.a in Frameworks */, - 5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */, + 5C41159E280048E90054D6CB /* libgmpxx.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -256,13 +168,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059E0279559F40002BEB4 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -297,11 +202,11 @@ 5C764E5C279C70B7000C6508 /* Libraries */ = { isa = PBXGroup; children = ( - 5CA14D2127F6DE37009B11CE /* libffi.a */, - 5CA14D1F27F6DE37009B11CE /* libgmp.a */, - 5CA14D2027F6DE37009B11CE /* libgmpxx.a */, - 5CA14D2227F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to-ghc8.10.7.a */, - 5CA14D1E27F6DE37009B11CE /* libHSsimplex-chat-1.4.1-BTiQTwPdJ1X1QlTjXA35to.a */, + 5C411594280048E90054D6CB /* libffi.a */, + 5C411597280048E90054D6CB /* libgmp.a */, + 5C411596280048E90054D6CB /* libgmpxx.a */, + 5C411595280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx-ghc8.10.7.a */, + 5C411593280048E90054D6CB /* libHSsimplex-chat-1.5.0-3uBn0HoMpg08OGLfasXsOx.a */, ); path = Libraries; sourceTree = ""; @@ -336,6 +241,8 @@ 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */, 5C5F2B6C27EBC3FE006A9D5F /* ImagePicker.swift */, 5C5F2B6F27EBC704006A9D5F /* ProfileImage.swift */, + 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */, + 3CDBCF4727FF621E00354CDD /* ChatItemLinkView.swift */, ); path = Helpers; sourceTree = ""; @@ -343,12 +250,12 @@ 5CA059BD279559F40002BEB4 = { isa = PBXGroup; children = ( + 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, + 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C764E5C279C70B7000C6508 /* Libraries */, 5CA059C2279559F40002BEB4 /* Shared */, - 5CA059D1279559F40002BEB4 /* macOS */, 5CA059DA279559F40002BEB4 /* Tests iOS */, - 5CA059E6279559F40002BEB4 /* Tests macOS */, 5CA059CB279559F40002BEB4 /* Products */, 5C764E7A279C71D4000C6508 /* Frameworks */, ); @@ -363,7 +270,6 @@ 5C2E260D27A30E2400F70299 /* Views */, 5CA059C5279559F40002BEB4 /* Assets.xcassets */, 5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */, - 5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */, 5C764E7F279C7276000C6508 /* dummy.m */, ); path = Shared; @@ -373,20 +279,11 @@ isa = PBXGroup; children = ( 5CA059CA279559F40002BEB4 /* SimpleX.app */, - 5CA059D0279559F40002BEB4 /* SimpleX.app */, 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */, - 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */, ); name = Products; sourceTree = ""; }; - 5CA059D1279559F40002BEB4 /* macOS */ = { - isa = PBXGroup; - children = ( - ); - path = macOS; - sourceTree = ""; - }; 5CA059DA279559F40002BEB4 /* Tests iOS */ = { isa = PBXGroup; children = ( @@ -396,15 +293,6 @@ path = "Tests iOS"; sourceTree = ""; }; - 5CA059E6279559F40002BEB4 /* Tests macOS */ = { - isa = PBXGroup; - children = ( - 5CA059E7279559F40002BEB4 /* Tests_macOS.swift */, - 5CA059E9279559F40002BEB4 /* Tests_macOSLaunchTests.swift */, - ); - path = "Tests macOS"; - sourceTree = ""; - }; 5CB924DD27A8622200ACCCDD /* NewChat */ = { isa = PBXGroup; children = ( @@ -487,23 +375,6 @@ productReference = 5CA059CA279559F40002BEB4 /* SimpleX.app */; productType = "com.apple.product-type.application"; }; - 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5CA059F6279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (macOS)" */; - buildPhases = ( - 5CA059CC279559F40002BEB4 /* Sources */, - 5CA059CD279559F40002BEB4 /* Frameworks */, - 5CA059CE279559F40002BEB4 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "SimpleX (macOS)"; - productName = "SimpleX (macOS)"; - productReference = 5CA059D0279559F40002BEB4 /* SimpleX.app */; - productType = "com.apple.product-type.application"; - }; 5CA059D6279559F40002BEB4 /* Tests iOS */ = { isa = PBXNativeTarget; buildConfigurationList = 5CA059F9279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests iOS" */; @@ -522,24 +393,6 @@ productReference = 5CA059D7279559F40002BEB4 /* Tests iOS.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; - 5CA059E2279559F40002BEB4 /* Tests macOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5CA059FC279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests macOS" */; - buildPhases = ( - 5CA059DF279559F40002BEB4 /* Sources */, - 5CA059E0279559F40002BEB4 /* Frameworks */, - 5CA059E1279559F40002BEB4 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 5CA059E5279559F40002BEB4 /* PBXTargetDependency */, - ); - name = "Tests macOS"; - productName = "Tests macOS"; - productReference = 5CA059E3279559F40002BEB4 /* Tests macOS.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -555,18 +408,10 @@ CreatedOnToolsVersion = 13.2.1; LastSwiftMigration = 1320; }; - 5CA059CF279559F40002BEB4 = { - CreatedOnToolsVersion = 13.2.1; - LastSwiftMigration = 1320; - }; 5CA059D6279559F40002BEB4 = { CreatedOnToolsVersion = 13.2.1; TestTargetID = 5CA059C9279559F40002BEB4; }; - 5CA059E2279559F40002BEB4 = { - CreatedOnToolsVersion = 13.2.1; - TestTargetID = 5CA059CF279559F40002BEB4; - }; }; }; buildConfigurationList = 5CA059C1279559F40002BEB4 /* Build configuration list for PBXProject "SimpleX" */; @@ -576,6 +421,7 @@ knownRegions = ( en, Base, + ru, ); mainGroup = 5CA059BD279559F40002BEB4; packageReferences = ( @@ -586,9 +432,7 @@ projectRoot = ""; targets = ( 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */, - 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */, 5CA059D6279559F40002BEB4 /* Tests iOS */, - 5CA059E2279559F40002BEB4 /* Tests macOS */, ); }; /* End PBXProject section */ @@ -599,14 +443,8 @@ buildActionMask = 2147483647; files = ( 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 5CA059CE279559F40002BEB4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 5CA059F0279559F40002BEB4 /* Assets.xcassets in Resources */, + 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, + 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -617,13 +455,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059E1279559F40002BEB4 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -637,6 +468,8 @@ 5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */, 5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */, 5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */, + 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */, + 3CDBCF4827FF621E00354CDD /* ChatItemLinkView.swift in Sources */, 5C764E80279C7276000C6508 /* dummy.m in Sources */, 5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, 5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */, @@ -679,58 +512,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059CC279559F40002BEB4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */, - 5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */, - 5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */, - 5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */, - 5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */, - 5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */, - 5C764E81279C7276000C6508 /* dummy.m in Sources */, - 5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */, - 5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */, - 5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */, - 640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */, - 5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */, - 5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */, - 5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */, - 5C9FD96C27A56D4D0075386C /* JSON.swift in Sources */, - 5C9FD96F27A5D6ED0075386C /* SendMessageView.swift in Sources */, - 5CC1C99327A6C7F5000D9FF6 /* QRCode.swift in Sources */, - 5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */, - 5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */, - 5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */, - 5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */, - 5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */, - 5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */, - 5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */, - 5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */, - 5CA05A4D27974EB60002BEB4 /* WelcomeView.swift in Sources */, - 5C2E261027A30FDC00F70299 /* ChatView.swift in Sources */, - 5C2E260C27A30CFA00F70299 /* ChatListView.swift in Sources */, - 5C971E2227AEBF8300C8A3CE /* ChatInfoImage.swift in Sources */, - 5C5F2B6E27EBC3FE006A9D5F /* ImagePicker.swift in Sources */, - 5C577F7E27C83AA10006112D /* MarkdownHelp.swift in Sources */, - 5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */, - 5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */, - 5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */, - 5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */, - 5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */, - 5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */, - 5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */, - 5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */, - 5CB924D527A853F100ACCCDD /* SettingsButton.swift in Sources */, - 5C5F2B7127EBC704006A9D5F /* ProfileImage.swift in Sources */, - 64AA1C6D27F3537400AC7277 /* DeletedItemView.swift in Sources */, - 5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */, - 5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */, - 64AA1C6A27EE10C800AC7277 /* ContextItemView.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5CA059D3279559F40002BEB4 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -740,15 +521,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5CA059DF279559F40002BEB4 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 5CA059EA279559F40002BEB4 /* Tests_macOSLaunchTests.swift in Sources */, - 5CA059E8279559F40002BEB4 /* Tests_macOS.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -757,13 +529,27 @@ target = 5CA059C9279559F40002BEB4 /* SimpleX (iOS) */; targetProxy = 5CA059D8279559F40002BEB4 /* PBXContainerItemProxy */; }; - 5CA059E5279559F40002BEB4 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5CA059CF279559F40002BEB4 /* SimpleX (macOS) */; - targetProxy = 5CA059E4279559F40002BEB4 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ +/* Begin PBXVariantGroup section */ + 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CC2C0FB2809BF11000C35E3 /* ru */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */ = { + isa = PBXVariantGroup; + children = ( + 5CC2C0FE2809BF11000C35E3 /* ru */, + ); + name = "SimpleX--iOS--InfoPlist.strings"; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ 5CA059F1279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; @@ -820,6 +606,7 @@ MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -872,6 +659,7 @@ MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; }; name = Release; @@ -956,83 +744,6 @@ }; name = Release; }; - 5CA059F7279559F40002BEB4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = "SimpleX (macOS)Debug.entitlements"; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5NN7GUYB6T; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "SimpleX--macOS--Info.plist"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - "$(PROJECT_DIR)/Libraries/ios", - "$(PROJECT_DIR)/Libraries/sim", - ); - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; - PRODUCT_NAME = SimpleX; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (macOS)-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 5CA059F8279559F40002BEB4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = macOS/macOS.entitlements; - CODE_SIGN_IDENTITY = "-"; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 5NN7GUYB6T; - ENABLE_HARDENED_RUNTIME = YES; - ENABLE_PREVIEWS = YES; - GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = "SimpleX--macOS--Info.plist"; - INFOPLIST_KEY_NSHumanReadableCopyright = ""; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Libraries", - "$(PROJECT_DIR)/Libraries/ios", - "$(PROJECT_DIR)/Libraries/sim", - ); - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; - PRODUCT_NAME = SimpleX; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = YES; - SWIFT_OBJC_BRIDGING_HEADER = "Shared/SimpleX (macOS)-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; 5CA059FA279559F40002BEB4 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1074,44 +785,6 @@ }; name = Release; }; - 5CA059FD279559F40002BEB4 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9767FTRA3G; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-macOS"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = "SimpleX (macOS)"; - }; - name = Debug; - }; - 5CA059FE279559F40002BEB4 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 9767FTRA3G; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 12.1; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-macOS"; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TEST_TARGET_NAME = "SimpleX (macOS)"; - }; - name = Release; - }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -1133,15 +806,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5CA059F6279559F40002BEB4 /* Build configuration list for PBXNativeTarget "SimpleX (macOS)" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5CA059F7279559F40002BEB4 /* Debug */, - 5CA059F8279559F40002BEB4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 5CA059F9279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests iOS" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1151,15 +815,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5CA059FC279559F40002BEB4 /* Build configuration list for PBXNativeTarget "Tests macOS" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5CA059FD279559F40002BEB4 /* Debug */, - 5CA059FE279559F40002BEB4 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme new file mode 100644 index 0000000000..a90949ee3b --- /dev/null +++ b/apps/ios/SimpleX.xcodeproj/xcshareddata/xcschemes/SimpleX (iOS).xcscheme @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/ios/Tests macOS/Tests_macOS.swift b/apps/ios/Tests macOS/Tests_macOS.swift deleted file mode 100644 index ee05450dc0..0000000000 --- a/apps/ios/Tests macOS/Tests_macOS.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// Tests_macOS.swift -// Tests macOS -// -// Created by Evgeny Poberezkin on 17/01/2022. -// - -import XCTest - -class Tests_macOS: XCTestCase { - - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use recording to get started writing UI tests. - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - - func testLaunchPerformance() throws { - if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } - } - } -} diff --git a/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift b/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift deleted file mode 100644 index 84d51dadbd..0000000000 --- a/apps/ios/Tests macOS/Tests_macOSLaunchTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// Tests_macOSLaunchTests.swift -// Tests macOS -// -// Created by Evgeny Poberezkin on 17/01/2022. -// - -import XCTest - -class Tests_macOSLaunchTests: XCTestCase { - - override class var runsForEachTargetApplicationUIConfiguration: Bool { - true - } - - override func setUpWithError() throws { - continueAfterFailure = false - } - - func testLaunch() throws { - let app = XCUIApplication() - app.launch() - - // Insert steps here to perform after app launch but before taking a screenshot, - // such as logging into a test account or navigating somewhere in the app - - let attachment = XCTAttachment(screenshot: app.screenshot()) - attachment.name = "Launch Screen" - attachment.lifetime = .keepAlways - add(attachment) - } -} diff --git a/apps/ios/macOS/macOS.entitlements b/apps/ios/macOS/macOS.entitlements deleted file mode 100644 index f2ef3ae026..0000000000 --- a/apps/ios/macOS/macOS.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - - diff --git a/apps/ios/ru.lproj/Localizable.strings b/apps/ios/ru.lproj/Localizable.strings new file mode 100644 index 0000000000..2ceedaa689 --- /dev/null +++ b/apps/ios/ru.lproj/Localizable.strings @@ -0,0 +1,462 @@ +/* No comment provided by engineer. */ +" " = " "; + +/* No comment provided by engineer. */ +" (" = " ("; + +/* No comment provided by engineer. */ +" (can be copied)" = " (ΠΌΠΎΠΆΠ½ΠΎ ΡΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ)"; + +/* No comment provided by engineer. */ +"_italic_" = "\\_курсив_"; + +/* No comment provided by engineer. */ +", " = ", "; + +/* No comment provided by engineer. */ +": " = ": "; + +/* No comment provided by engineer. */ +": %@" = ": %@"; + +/* No comment provided by engineer. */ +"!1 colored!" = "!1 Ρ†Π²Π΅Ρ‚!"; + +/* No comment provided by engineer. */ +"(shared only with your contacts)" = "(отправляСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°ΠΌ)"; + +/* No comment provided by engineer. */ +")" = ")"; + +/* No comment provided by engineer. */ +"[Send us email](mailto:chat@simplex.chat)" = "[ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ email](mailto:chat@simplex.chat)"; + +/* No comment provided by engineer. */ +"**Add new contact**: to create your one-time QR Code for your contact." = "**Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚**: Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΎΠ΄Π½ΠΎΡ€Π°Π·ΠΎΠ²Ρ‹ΠΉ QR ΠΊΠΎΠ΄ ΠΈΠ»ΠΈ ссылку для вашСго ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°."; + +/* No comment provided by engineer. */ +"**Scan QR code**: to connect to your contact who shows QR code to you." = "**Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄**: Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ΠΎΠΌ (ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΠΏΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Π²Π°ΠΌ QR ΠΊΠΎΠ΄)."; + +/* No comment provided by engineer. */ +"*bold*" = "\\*ΠΆΠΈΡ€Π½Ρ‹ΠΉ*"; + +/* No comment provided by engineer. */ +"#secret#" = "#сСкрСт#"; + +/* notification title */ +"%@ is connected!" = "УстановлСно соСдинСниС с %@!"; + +/* notification title */ +"%@ wants to connect!" = "%@ Ρ…ΠΎΡ‡Π΅Ρ‚ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ!"; + +/* No comment provided by engineer. */ +"%lld" = "%lld"; + +/* No comment provided by engineer. */ +"%lldk" = "%lldk"; + +/* No comment provided by engineer. */ +"`a + b`" = "\\`a + b`"; + +/* No comment provided by engineer. */ +"~strike~" = "\\~Π·Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚ΡŒ~"; + +/* No comment provided by engineer. */ +"πŸ’» desktop: scan displayed QR code from the app, via **Scan QR code**." = "πŸ’» Π½Π° ΠΊΠΎΠΌΠΏΡŒΡŽΡ‚Π΅Ρ€Π΅: сосканируйтС QR ΠΊΠΎΠ΄ ΠΈΠ· прилоТСния Ρ‡Π΅Ρ€Π΅Π· **Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄**."; + +/* No comment provided by engineer. */ +"πŸ“± mobile: tap **Open in mobile app**, then tap **Connect** in the app." = "πŸ“± Π½Π° мобильном: Π½Π°ΠΌΠΆΠΈΡ‚Π΅ ΠΊΠ½ΠΎΠΏΠΊΡƒ **Open in mobile app** Π½Π° Π²Π΅Π± страницС, Π·Π°Ρ‚Π΅ΠΌ Π½Π°ΠΆΠΌΠΈΡ‚Π΅ **Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ** Π² ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΈ."; + +/* No comment provided by engineer. */ +"6" = "6"; + +/* No comment provided by engineer. */ +"above, then:" = "Π½Π°Π²Π΅Ρ€Ρ…Ρƒ, Π·Π°Ρ‚Π΅ΠΌ:"; + +/* accept contact request via notification */ +"Accept" = "ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Accept contact" = "ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ запрос"; + +/* notification body */ +"Accept contact request from %@?" = "ΠŸΡ€ΠΈΠ½ΡΡ‚ΡŒ запрос Π½Π° соСдинСниС ΠΎΡ‚ %@?"; + +/* No comment provided by engineer. */ +"Add contact" = "Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚"; + +/* No comment provided by engineer. */ +"All your contacts will remain connected" = "ВсС ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ соСдинились Ρ‡Π΅Ρ€Π΅Π· этот адрСс, сохранятся."; + +/* No comment provided by engineer. */ +"bold" = "ΠΆΠΈΡ€Π½Ρ‹ΠΉ"; + +/* No comment provided by engineer. */ +"Cancel" = "ΠžΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Chat console" = "Консоль"; + +/* No comment provided by engineer. */ +"Chat with the developers" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ°ΠΌΠΈ"; + +/* back button to return to chats list */ +"Chats" = "Назад"; + +/* No comment provided by engineer. */ +"Choose from library" = "Π’Ρ‹Π±Ρ€Π°Ρ‚ΡŒ ΠΈΠ· Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΠΈ"; + +/* No comment provided by engineer. */ +"colored" = "Ρ†Π²Π΅Ρ‚"; + +/* No comment provided by engineer. */ +"Configure SMP servers" = "Настройка SMP сСрвСров"; + +/* No comment provided by engineer. */ +"Confirm" = "ΠŸΠΎΠ΄Ρ‚Π²Π΅Ρ€Π΄ΠΈΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Connect" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ"; + +/* No comment provided by engineer. */ +"connect to SimpleX Chat developers." = "ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠ°ΠΌΠΈ."; + +/* No comment provided by engineer. */ +"Connect via contact link?" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку-ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚?"; + +/* No comment provided by engineer. */ +"Connect via invitation link?" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку-ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅?"; + +/* No comment provided by engineer. */ +"Connecting server…" = "УстанавливаСтся соСдинСниС с сСрвСром…"; + +/* No comment provided by engineer. */ +"Connecting server… (error: %@)" = "УстанавливаСтся соСдинСниС с сСрвСром… (ошибка: %@)"; + +/* No comment provided by engineer. */ +"Connecting..." = "УстанавливаСтся соСдинСниС…"; + +/* No comment provided by engineer. */ +"Connection error" = "Ошибка соСдинСния"; + +/* No comment provided by engineer. */ +"Connection request" = "Запрос Π½Π° соСдинСниС"; + +/* No comment provided by engineer. */ +"Connection request sent!" = "Запрос Π½Π° соСдинСниС ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½!"; + +/* No comment provided by engineer. */ +"Connection timeout" = "ΠŸΡ€Π΅Π²Ρ‹ΡˆΠ΅Π½ΠΎ врСмя соСдинСния"; + +/* No comment provided by engineer. */ +"Contact already exists" = "Π‘ΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚"; + +/* No comment provided by engineer. */ +"Contact and all messages will be deleted - this cannot be undone!" = "ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΈ всС сообщСния Π±ΡƒΠ΄ΡƒΡ‚ ΡƒΠ΄Π°Π»Π΅Π½Ρ‹ - это дСйствиС нСльзя ΠΎΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ!"; + +/* notification */ +"Contact is connected" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ с ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ΠΎΠΌ установлСно"; + +/* No comment provided by engineer. */ +"Copy" = "Π‘ΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Create" = "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Create address" = "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ адрСс"; + +/* No comment provided by engineer. */ +"Create group" = "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ Π³Ρ€ΡƒΠΏΠΏΡƒ"; + +/* No comment provided by engineer. */ +"Create profile" = "Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ"; + +/* No comment provided by engineer. */ +"Delete" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Delete address" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ адрСс"; + +/* No comment provided by engineer. */ +"Delete address?" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ адрСс?"; + +/* No comment provided by engineer. */ +"Delete contact" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚"; + +/* No comment provided by engineer. */ +"Delete contact?" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚?"; + +/* No comment provided by engineer. */ +"Delete for me" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ для мСня"; + +/* No comment provided by engineer. */ +"Delete group" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ Π³Ρ€ΡƒΠΏΠΏΡƒ"; + +/* No comment provided by engineer. */ +"Delete message?" = "Π£Π΄Π°Π»ΠΈΡ‚ΡŒ сообщСниС?"; + +/* deleted chat item */ +"deleted" = "ΡƒΠ΄Π°Π»Π΅Π½ΠΎ"; + +/* No comment provided by engineer. */ +"Develop" = "Для Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠΎΠ²"; + +/* No comment provided by engineer. */ +"Display name" = "Имя профиля"; + +/* No comment provided by engineer. */ +"Edit" = "Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Enter one SMP server per line:" = "Π’Π²Π΅Π΄ΠΈΡ‚Π΅ SMP сСрвСры, ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ Π½Π° ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ строкС:"; + +/* No comment provided by engineer. */ +"Error saving SMP servers" = "Ошибка ΠΏΡ€ΠΈ сохранСнии SMP сСрвСров"; + +/* No comment provided by engineer. */ +"Error: %@" = "Ошибка: %@"; + +/* No comment provided by engineer. */ +"Error: URL is invalid" = "Ошибка: нСвСрная ссылка"; + +/* No comment provided by engineer. */ +"Full name (optional)" = "ПолноС имя (Π½Π΅ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ)"; + +/* No comment provided by engineer. */ +"Group deletion is not supported" = "Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ Π³Ρ€ΡƒΠΏΠΏ Π½Π΅ поддСрТиваСтся"; + +/* No comment provided by engineer. */ +"Help" = "ΠŸΠΎΠΌΠΎΡ‰ΡŒ"; + +/* No comment provided by engineer. */ +"How to" = "Π˜Π½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡ"; + +/* No comment provided by engineer. */ +"How to use markdown" = "Как Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ"; + +/* No comment provided by engineer. */ +"How to use SimpleX Chat" = "Как ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ SimpleX Chat"; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link." = "Если Π²Ρ‹ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Π²ΡΡ‚Ρ€Π΅Ρ‚ΠΈΡ‚ΡŒΡΡ Π»ΠΈΡ‡Π½ΠΎ, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ **ΡΠΎΡΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ Π²ΠΎ врСмя Π²ΠΈΠ΄Π΅ΠΎΠ·Π²ΠΎΠ½ΠΊΠ°**, ΠΈΠ»ΠΈ ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π²Π°ΠΌ ссылку."; + +/* No comment provided by engineer. */ +"If you cannot meet in person, you can **show QR code in the video call**, or you can share the invitation link via any other channel." = "Если Π²Ρ‹ Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Π²ΡΡ‚Ρ€Π΅Ρ‚ΠΈΡ‚ΡŒΡΡ Π»ΠΈΡ‡Π½ΠΎ, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ **ΠΏΠΎΠΊΠ°Π·Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄ Π²ΠΎ врСмя Π²ΠΈΠ΄Π΅ΠΎΠ·Π²ΠΎΠ½ΠΊΠ°** ΠΈΠ»ΠΈ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ ссылку Ρ‡Π΅Ρ€Π΅Π· любой Π΄Ρ€ΡƒΠ³ΠΎΠΉ ΠΊΠ°Π½Π°Π» связи."; + +/* No comment provided by engineer. */ +"If you received SimpleX Chat invitation link you can open it in your browser:" = "Если Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΠ»ΠΈ ссылку с ΠΏΡ€ΠΈΠ³Π»Π°ΡˆΠ΅Π½ΠΈΠ΅ΠΌ ΠΈΠ· SimpleX Chat, Π²Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Π΅Ρ‘ Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π΅:"; + +/* No comment provided by engineer. */ +"Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)" = "[SimpleX Chat для Ρ‚Π΅Ρ€ΠΌΠΈΠ½Π°Π»Π°](https://github.com/simplex-chat/simplex-chat)"; + +/* No comment provided by engineer. */ +"Invalid connection link" = "Ошибка Π² ссылкС ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°"; + +/* No comment provided by engineer. */ +"italic" = "курсив"; + +/* No comment provided by engineer. */ +"Make sure SMP server addresses are in correct format, line separated and are not duplicated." = "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅, Ρ‡Ρ‚ΠΎ адрСса SMP сСрвСров ΠΈΠΌΠ΅ΡŽΡ‚ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚, ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ адрСс Π½Π° ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠΉ строкС ΠΈ Π½Π΅ повторяСтся."; + +/* No comment provided by engineer. */ +"Markdown in messages" = "Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ сообщСний"; + +/* No comment provided by engineer. */ +"Message delivery error" = "Ошибка доставки сообщСния"; + +/* No comment provided by engineer. */ +"Most likely this contact has deleted the connection with you." = "Π‘ΠΊΠΎΡ€Π΅Π΅ всСго, этот ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΡƒΠ΄Π°Π»ΠΈΠ» соСдинСниС с Π²Π°ΠΌΠΈ."; + +/* notification */ +"New contact request" = "Новый запрос Π½Π° соСдинСниС"; + +/* notifications */ +"New message" = "НовоС сообщСниС"; + +/* No comment provided by engineer. */ +"Notifications are disabled!" = "УвСдомлСния Π²Ρ‹ΠΊΠ»ΡŽΡ‡Π΅Π½Ρ‹"; + +/* No comment provided by engineer. */ +"Open Settings" = "ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚ΡŒ Настройки"; + +/* No comment provided by engineer. */ +"Please check that you used the correct link or ask your contact to send you another one." = "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅, Ρ‡Ρ‚ΠΎ Π²Ρ‹ использовали ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½ΡƒΡŽ ссылку ΠΈΠ»ΠΈ попроситС, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΠ» Π²Π°ΠΌ Π΄Ρ€ΡƒΠ³ΡƒΡŽ ссылку."; + +/* No comment provided by engineer. */ +"Please check your network connection and try again." = "ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ вашС соСдинСниС с ΡΠ΅Ρ‚ΡŒΡŽ ΠΈ ΠΏΠΎΠΏΡ€ΠΎΠ±ΡƒΠΉΡ‚Π΅ Π΅Ρ‰Π΅ Ρ€Π°Π·."; + +/* No comment provided by engineer. */ +"Profile image" = "Аватар"; + +/* No comment provided by engineer. */ +"Read" = "ΠŸΡ€ΠΎΡ‡ΠΈΡ‚Π°Π½ΠΎ"; + +/* to be removed */ +"receiving files is not supported yet" = "ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠ΅ Ρ„Π°ΠΉΠ»ΠΎΠ² Π½Π΅ поддСрТиваСтся"; + +/* No comment provided by engineer. */ +"Reject" = "ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Reject contact (sender NOT notified)" = "ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ (Π½Π΅ увСдомляя отправитСля)"; + +/* No comment provided by engineer. */ +"Reject contact request" = "ΠžΡ‚ΠΊΠ»ΠΎΠ½ΠΈΡ‚ΡŒ запрос"; + +/* No comment provided by engineer. */ +"Reply" = "ΠžΡ‚Π²Π΅Ρ‚ΠΈΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Save" = "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Save (and notify contacts)" = "Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ (ΠΈ ΡƒΠ²Π΅Π΄ΠΎΠΌΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹)"; + +/* No comment provided by engineer. */ +"Saved SMP servers will be removed" = "Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½Π½Ρ‹Π΅ SMP сСрвСры Π±ΡƒΠ΄ΡƒΡ‚ ΡƒΠ΄Π°Π»Π΅Π½Ρ‹"; + +/* No comment provided by engineer. */ +"Scan QR code" = "Π‘ΠΊΠ°Π½ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ QR ΠΊΠΎΠ΄"; + +/* No comment provided by engineer. */ +"secret" = "сСкрСт"; + +/* to be removed */ +"sending files is not supported yet" = "ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ° Ρ„Π°ΠΉΠ»ΠΎΠ² Π½Π΅ поддСрТиваСтся"; + +/* No comment provided by engineer. */ +"Server connected" = "УстановлСно соСдинСниС с сСрвСром"; + +/* No comment provided by engineer. */ +"Settings" = "Настройки"; + +/* No comment provided by engineer. */ +"Share" = "ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ"; + +/* No comment provided by engineer. */ +"Share invitation link" = "ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ ссылкой"; + +/* No comment provided by engineer. */ +"Share link" = "ΠŸΠΎΠ΄Π΅Π»ΠΈΡ‚ΡŒΡΡ ссылкой"; + +/* No comment provided by engineer. */ +"Show QR code to your contact\nto scan from the app" = "ΠŸΠΎΠΊΠ°ΠΆΠΈΡ‚Π΅ QR ΠΊΠΎΠ΄ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ для сканирования Π² ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΈ"; + +/* No comment provided by engineer. */ +"SMP servers" = "SMP сСрвСры"; + +/* No comment provided by engineer. */ +"Start new chat" = "ΠΠ°Ρ‡Π°Ρ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ Ρ€Π°Π·Π³ΠΎΠ²ΠΎΡ€"; + +/* No comment provided by engineer. */ +"strike" = "Π·Π°Ρ‡Π΅Ρ€ΠΊΠ½ΡƒΡ‚ΡŒ"; + +/* No comment provided by engineer. */ +"Take picture" = "Π‘Π΄Π΅Π»Π°Ρ‚ΡŒ Ρ„ΠΎΡ‚ΠΎ"; + +/* No comment provided by engineer. */ +"Tap button " = "НаТмитС ΠΊΠ½ΠΎΠΏΠΊΡƒ"; + +/* No comment provided by engineer. */ +"Thank you for installing SimpleX Chat!" = "Бпасибо, Ρ‡Ρ‚ΠΎ Π’Ρ‹ установили SimpleX Chat!"; + +/* No comment provided by engineer. */ +"The app can notify you when you receive messages or contact requests - please open settings to enable." = "ΠŸΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠ΅ ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠΎΡΡ‹Π»Π°Ρ‚ΡŒ Π²Π°ΠΌ увСдомлСния ΠΎ сообщСниях ΠΈ запросах Π½Π° соСдинСниС - увСдомлСния ΠΌΠΎΠΆΠ½ΠΎ Π²ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ Π² Настройках."; + +/* No comment provided by engineer. */ +"The messaging and application platform 100% private by design!" = "ΠŸΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΠ° для сообщСний ΠΈ ΠΏΡ€ΠΈΠ»ΠΎΠΆΠ΅Π½ΠΈΠΉ, которая Π·Π°Ρ‰ΠΈΡ‰Π°Π΅Ρ‚ Π²Π°ΡˆΡƒ Π»ΠΈΡ‡Π½ΡƒΡŽ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ ΠΈ Π±Π΅Π·ΠΎΠΏΠ°ΡΠ½ΠΎΡΡ‚ΡŒ."; + +/* No comment provided by engineer. */ +"The sender will NOT be notified" = "ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚Π΅Π»ΡŒ Π½Π΅ Π±ΡƒΠ΄Π΅Ρ‚ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Ρ‘Π½"; + +/* No comment provided by engineer. */ +"To ask any questions and to receive updates:" = "Π—Π°Π΄Π°Ρ‚ΡŒ вопросы ΠΈ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ увСдомлСния ΠΎ Π½ΠΎΠ²Ρ‹Ρ… вСрсиях:"; + +/* No comment provided by engineer. */ +"To connect via link" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ Ρ‡Π΅Ρ€Π΅Π· ссылку"; + +/* No comment provided by engineer. */ +"To start a new chat" = "ΠΠ°Ρ‡Π°Ρ‚ΡŒ Π½ΠΎΠ²Ρ‹ΠΉ Ρ€Π°Π·Π³ΠΎΠ²ΠΎΡ€"; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact (error: %@)." = "УстанавливаСтся соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π° (ошибка: %@)."; + +/* No comment provided by engineer. */ +"Trying to connect to the server used to receive messages from this contact." = "УстанавливаСтся соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚Π΅ сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°."; + +/* No comment provided by engineer. */ +"Unexpected error: %@" = "НСоТиданная ошибка: %@"; + +/* No comment provided by engineer. */ +"Use SimpleX Chat servers?" = "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ сСрвСры прСдосталСнныС SimpleX Chat?"; + +/* No comment provided by engineer. */ +"Using SimpleX Chat servers." = "Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΡŽΡ‚ΡΡ сСрвСры, прСдоставлСнныС SimpleX Chat."; + +/* No comment provided by engineer. */ +"v%@ (%@)" = "v%@ (%@)"; + +/* No comment provided by engineer. */ +"wants to connect to you!" = "Ρ…ΠΎΡ‡Π΅Ρ‚ ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Π²Π°ΠΌΠΈ!"; + +/* No comment provided by engineer. */ +"Welcome %@!" = "ЗдравствуйтС %@!"; + +/* No comment provided by engineer. */ +"You" = "Π’Ρ‹"; + +/* No comment provided by engineer. */ +"You are already connected to %@ via this link." = "Π’Ρ‹ ΡƒΠΆΠ΅ соСдинСны с %@ Ρ‡Π΅Ρ€Π΅Π· эту ссылку."; + +/* No comment provided by engineer. */ +"You are connected to the server used to receive messages from this contact." = "УстановлСно соСдинСниС с сСрвСром, Ρ‡Π΅Ρ€Π΅Π· ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ Π²Ρ‹ получаСтся сообщСния ΠΎΡ‚ этого ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°."; + +/* notification body */ +"You can now send messages to %@" = "Π’Ρ‹ Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ сообщСния %@"; + +/* No comment provided by engineer. */ +"You can share your address as a link or as a QR code - anybody will be able to connect to you. You won't lose your contacts if you later delete it." = "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ ваш адрСс ΠΊΠ°ΠΊ ссылку ΠΈΠ»ΠΈ ΠΊΠ°ΠΊ QR ΠΊΠΎΠ΄ - ΠΊΡ‚ΠΎ ΡƒΠ³ΠΎΠ΄Π½ΠΎ смоТСт ΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ с Π²Π°ΠΌΠΈ. Π’Ρ‹ смоТСтС ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ адрСс, сохранив ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ Ρ‡Π΅Ρ€Π΅Π· Π½Π΅Π³ΠΎ соСдинились."; + +/* No comment provided by engineer. */ +"You can use markdown to format messages:" = "Π’Ρ‹ ΠΌΠΎΠΆΠ΅Ρ‚Π΅ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ сообщСния:"; + +/* No comment provided by engineer. */ +"You control your chat!" = "Π’Ρ‹ ΠΊΠΎΡ‚Ρ€ΠΎΠ»ΠΈΡ€ΡƒΠ΅Ρ‚Π΅ Π’Π°Ρˆ Ρ‡Π°Ρ‚!"; + +/* No comment provided by engineer. */ +"You will be connected when your connection request is accepted, please wait or check later!" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ установлСно, ΠΊΠΎΠ³Π΄Π° ваш запрос Π±ΡƒΠ΄Π΅Ρ‚ принят. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅!"; + +/* No comment provided by engineer. */ +"You will be connected when your contact's device is online, please wait or check later!" = "Π‘ΠΎΠ΅Π΄ΠΈΠ½Π΅Π½ΠΈΠ΅ Π±ΡƒΠ΄Π΅Ρ‚ установлСно, ΠΊΠΎΠ³Π΄Π° ваш ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΠ½Π»Π°ΠΉΠ½. ΠŸΠΎΠΆΠ°Π»ΡƒΠΉΡΡ‚Π°, ΠΏΠΎΠ΄ΠΎΠΆΠ΄ΠΈΡ‚Π΅ ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ ΠΏΠΎΠ·ΠΆΠ΅!"; + +/* No comment provided by engineer. */ +"Your chat address" = "Π’Π°Ρˆ SimpleX адрСс"; + +/* No comment provided by engineer. */ +"Your chat profile" = "Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ"; + +/* No comment provided by engineer. */ +"Your chat profile will be sent to your contact" = "Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ"; + +/* No comment provided by engineer. */ +"Your chats" = "Π’Π°ΡˆΠΈ Ρ‡Π°Ρ‚Ρ‹"; + +/* No comment provided by engineer. */ +"Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile." = "Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ хранится Π½Π° вашСм устройствС ΠΈ отправляСтся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ вашим ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Π°ΠΌ.\nSimpleX сСрвСры Π½Π΅ ΠΌΠΎΠ³ΡƒΡ‚ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ доступ ΠΊ Π²Π°ΡˆΠ΅ΠΌΡƒ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŽ."; + +/* No comment provided by engineer. */ +"Your profile will be sent to the contact that you received this link from" = "Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ Π±ΡƒΠ΄Π΅Ρ‚ ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½ ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρƒ, ΠΎΡ‚ ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ³ΠΎ Π²Ρ‹ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΠ»ΠΈ эту ссылку."; + +/* No comment provided by engineer. */ +"Your profile, contacts and messages (once delivered) are only stored locally on your device." = "Π’Π°Ρˆ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ, ΠΊΠΎΠ½Ρ‚Π°ΠΊΡ‚Ρ‹ ΠΈ сообщСния (послС доставки) хранятся Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½Π° вашСм устройствС."; + +/* No comment provided by engineer. */ +"Your settings" = "Настройки"; + +/* No comment provided by engineer. */ +"Your SimpleX contact address" = "Π’Π°Ρˆ SimpleX адрСс"; + +/* No comment provided by engineer. */ +"Your SMP servers" = "Π’Π°ΡˆΠΈ SMP сСрвСры"; + diff --git a/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings new file mode 100644 index 0000000000..a8fa77d9f6 --- /dev/null +++ b/apps/ios/ru.lproj/SimpleX--iOS--InfoPlist.strings @@ -0,0 +1,6 @@ +/* Bundle name */ +"CFBundleName" = "SimpleX"; + +/* Privacy - Camera Usage Description */ +"NSCameraUsageDescription" = "SimpleX ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ ΠΊΠ°ΠΌΠ΅Ρ€Ρƒ для сканирования QR ΠΊΠΎΠ΄ΠΎΠ² ΠΏΡ€ΠΈ соСдинСнии с Π΄Ρ€ΡƒΠ³ΠΈΠΌΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡΠΌΠΈ"; + diff --git a/apps/simplex-bot-advanced/Main.hs b/apps/simplex-bot-advanced/Main.hs index 0e270a45a6..bb81911913 100644 --- a/apps/simplex-bot-advanced/Main.hs +++ b/apps/simplex-bot-advanced/Main.hs @@ -14,6 +14,7 @@ import qualified Data.Text as T import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller +import Simplex.Chat.Core import Simplex.Chat.Messages import Simplex.Chat.Options import Simplex.Chat.Types @@ -23,7 +24,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatBot defaultChatConfig opts mySquaringBot + simplexChatCore defaultChatConfig opts Nothing mySquaringBot welcomeGetOpts :: IO ChatOpts welcomeGetOpts = do @@ -50,5 +51,5 @@ mySquaringBot _user cc = do Just n -> msg <> " * " <> msg <> " = " <> show (n * n) _ -> pure () where - sendMsg Contact {contactId} msg = sendCmd cc $ "/_send @" <> show contactId <> " text " <> msg + sendMsg Contact {contactId} msg = sendChatCmd cc $ "/_send @" <> show contactId <> " text " <> msg contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" diff --git a/apps/simplex-bot/Main.hs b/apps/simplex-bot/Main.hs index 70faaf777e..1c322dbc18 100644 --- a/apps/simplex-bot/Main.hs +++ b/apps/simplex-bot/Main.hs @@ -5,6 +5,7 @@ module Main where import Simplex.Chat import Simplex.Chat.Bot import Simplex.Chat.Controller (versionNumber) +import Simplex.Chat.Core import Simplex.Chat.Options import System.Directory (getAppUserDataDirectory) import Text.Read @@ -12,7 +13,7 @@ import Text.Read main :: IO () main = do opts <- welcomeGetOpts - simplexChatBot defaultChatConfig opts $ + simplexChatCore defaultChatConfig opts Nothing $ chatBotRepl "Hello! I am a simple squaring bot - if you send me a number, I will calculate its square" $ \msg -> case readMaybe msg :: Maybe Integer of Just n -> msg <> " * " <> msg <> " = " <> show (n * n) diff --git a/apps/simplex-chat/Main.hs b/apps/simplex-chat/Main.hs index 0465354994..0a94c77935 100644 --- a/apps/simplex-chat/Main.hs +++ b/apps/simplex-chat/Main.hs @@ -2,24 +2,32 @@ module Main where +import Control.Concurrent (threadDelay) import Simplex.Chat import Simplex.Chat.Controller (versionNumber) +import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Terminal +import Simplex.Chat.View (serializeChatResponse) import System.Directory (getAppUserDataDirectory) import System.Terminal (withTerminal) main :: IO () main = do - opts <- welcomeGetOpts - t <- withTerminal pure - simplexChat defaultChatConfig opts t - -welcomeGetOpts :: IO ChatOpts -welcomeGetOpts = do appDir <- getAppUserDataDirectory "simplex" - opts@ChatOpts {dbFilePrefix} <- getChatOpts appDir "simplex_v1" + opts@ChatOpts {chatCmd} <- getChatOpts appDir "simplex_v1" + if null chatCmd + then do + welcome opts + t <- withTerminal pure + simplexChatTerminal defaultChatConfig opts t + else simplexChatCore defaultChatConfig opts Nothing $ \_ cc -> do + r <- sendChatCmd cc chatCmd + putStrLn $ serializeChatResponse r + threadDelay $ chatCmdDelay opts * 1000000 + +welcome :: ChatOpts -> IO () +welcome ChatOpts {dbFilePrefix} = do putStrLn $ "SimpleX Chat v" ++ versionNumber putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db" putStrLn "type \"/help\" or \"/h\" for usage info" - pure opts diff --git a/docs/CLI.md b/docs/CLI.md new file mode 100644 index 0000000000..1c49c8a1f5 --- /dev/null +++ b/docs/CLI.md @@ -0,0 +1,224 @@ +# SimpleX Chat terminal (console) app for Linux/MacOS/Windows + +## Table of contents + +- [Terminal chat features](#terminal-chat-features) +- [Installation](#πŸš€-installation) + - [Download chat client](#download-chat-client) + - [Linux and MacOS](#linux-and-macos) + - [Windows](#windows) + - [Build from source](#build-from-source) + - [Using Docker](#using-docker) + - [Using Haskell stack](#using-haskell-stack) +- [Usage](#usage) + - [Running the chat client](#running-the-chat-client) + - [How to use SimpleX chat](#how-to-use-simplex-chat) + - [Groups](#groups) + - [Sending files](#sending-files) + - [User contact addresses](#user-contact-addresses) + - [Access chat history](#access-chat-history) + +## Terminal chat features + +- 1-to-1 chat with multiple people in the same terminal window. +- Group messaging. +- Sending files to contacts and groups. +- User contact addresses - establish connections via multiple-use contact links. +- Messages persisted in a local SQLite database. +- Auto-populated recipient name - just type your messages to reply to the sender once the connection is established. +- Demo SMP servers available and pre-configured in the app - or you can [deploy your own server](https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent). +- No global identity or any names visible to the server(s), ensuring full privacy of your contacts and conversations. +- Two layers of E2E encryption (double-ratchet for duplex connections, using X3DH key agreement with ephemeral Curve448 keys, and NaCl crypto_box for SMP queues, using Curve25519 keys) and out-of-band passing of recipient keys (see [How to use SimpleX chat](#how-to-use-simplex-chat)). +- Message integrity validation (via including the digests of the previous messages). +- Authentication of each command/message by SMP servers with automatically generated Ed448 keys. +- TLS 1.3 transport encryption. +- Additional encryption of messages from SMP server to recipient to reduce traffic correlation. + +Public keys involved in key exchange are not used as identity, they are randomly generated for each contact. + +See [Encryption Primitives Used](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md#encryption-primitives-used) for technical details. + + + +## πŸš€ Installation + +### Download chat client + +#### Linux and MacOS + +To **install** or **update** `simplex-chat`, you should run the install script. To do that, use the following cURL or Wget command: + +```sh +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +```sh +wget -qO- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/install.sh | bash +``` + +Once the chat client downloads, you can run it with `simplex-chat` command in your terminal. + +Alternatively, you can manually download the chat binary for your system from the [latest stable release](https://github.com/simplex-chat/simplex-chat/releases) and make it executable as shown below. + +```sh +chmod +x +mv ~/.local/bin/simplex-chat +``` + +(or any other preferred location on `PATH`). + +On MacOS you also need to [allow Gatekeeper to run it](https://support.apple.com/en-us/HT202491). + +#### Windows + +```sh +move %APPDATA%/local/bin/simplex-chat.exe +``` + +### Build from source + +> **Please note:** to build the app use source code from [stable branch](https://github.com/simplex-chat/simplex-chat/tree/stable). + +#### Using Docker + +On Linux, you can build the chat executable using [docker build with custom output](https://docs.docker.com/engine/reference/commandline/build/#custom-build-outputs): + +```shell +$ git clone git@github.com:simplex-chat/simplex-chat.git +$ cd simplex-chat +$ git checkout stable +$ DOCKER_BUILDKIT=1 docker build --output ~/.local/bin . +``` + +> **Please note:** If you encounter `` version `GLIBC_2.28' not found `` error, rebuild it with `haskell:8.10.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)). + +#### Using Haskell stack + +Install [Haskell stack](https://docs.haskellstack.org/en/stable/README/): + +```shell +curl -sSL https://get.haskellstack.org/ | sh +``` + +and build the project: + +```shell +$ git clone git@github.com:simplex-chat/simplex-chat.git +$ cd simplex-chat +$ git checkout stable +$ stack install +``` + +## Usage + +### Running the chat client + +To start the chat client, run `simplex-chat` from the terminal. + +By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex_v1_chat.db` and `simplex_v1_agent.db` are initialized in it. + +To specify a different file path prefix for the database files use `-d` command line option: + +```shell +$ simplex-chat -d alice +``` + +Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory. + +Three default SMP servers are hosted on Linode - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/stable/src/Simplex/Chat/Options.hs#L42). + +If you deployed your own SMP server(s) you can configure client via `-s` option: + +```shell +$ simplex-chat -s smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@smp.example.com +``` + +Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake. + +You can still talk to people using default or any other server - it only affects the location of the message queue when you initiate the connection (and the reply queue can be on another server, as set by the other party's client). + +Run `simplex-chat -h` to see all available options. + +### How to use SimpleX chat + +Once you have started the chat, you will be prompted to specify your "display name" and an optional "full name" to create a local chat profile. Your display name is an alias for your contacts to refer to you by - it is not unique and does not serve as a global identity. If some of your contacts chose the same display name, the chat client adds a numeric suffix to their local display name. + +The diagram below shows how to connect and message a contact: + +
+ +
+ +Once you've set up your local profile, enter `/c` (for `/connect`) to create a new connection and generate an invitation. Send this invitation to your contact via any other channel. + +You are able to create multiple invitations by entering `/connect` multiple times and sending these invitations to the corresponding contacts you'd like to connect with. + +The invitation can only be used once and even if this is intercepted, the attacker would not be able to use it to send you the messages via this queue once your contact confirms that the connection is established. See agent protocol for explanation of [invitation format](https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md#connection-request). + +The contact who received the invitation should enter `/c ` to accept the connection. This establishes the connection, and both parties are notified. + +They would then use `@ ` commands to send messages. You may also just start typing a message to send it to the contact that was the last. + +Use `/help` in chat to see the list of available commands. + +### Groups + +To create a group use `/g `, then add contacts to it with `/a `. You can then send messages to the group by entering `# `. Use `/help groups` for other commands. + +![simplex-chat](./images/groups.gif) + +> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent. + +### Sending files + +You can send a file to your contact with `/f @ ` - the recipient will have to accept it before it is sent. Use `/help files` for other commands. + +![simplex-chat](./images/files.gif) + +You can send files to a group with `/f # `. + +### User contact addresses + +As an alternative to one-time invitation links, you can create a long-term address with `/ad` (for `/address`). The created address can then be shared via any channel, and used by other users as a link to make a contact request with `/c `. + +You can accept or reject incoming requests with `/ac ` and `/rc ` commands. + +User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you). + +Use `/help address` for other commands. + +![simplex-chat](./images/user-addresses.gif) + +### Access chat history + +SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user. + +You can view and search your chat history by querying your database. Run the below script to create message views in your database. + +```sh +curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/stable/scripts/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db +``` + +Open SQLite Command Line Shell: + +```sh +sqlite3 ~/.simplex/simplex_v1_chat.db +``` + +See [Message queries](./SQL.md) for examples. + +> **Please note:** SQLite foreign key constraints are disabled by default, and must be **[enabled separately for each database connection](https://sqlite.org/foreignkeys.html#fk_enable)**. The latter can be achieved by running `PRAGMA foreign_keys = ON;` command on an open database connection. By running data altering queries without enabling foreign keys prior to that, you may risk putting your database in an inconsistent state. + +**Convenience queries** + +Get all messages from today (`chat_dt` is in UTC): + +```sql +select * from all_messages_plain where date(chat_dt) > date('now', '-1 day') order by chat_dt; +``` + +Get overnight messages in the morning: + +```sql +select * from all_messages_plain where chat_dt > datetime('now', '-15 hours') order by chat_dt; +``` diff --git a/docs/SIMPLEX.md b/docs/SIMPLEX.md new file mode 100644 index 0000000000..86b4320bce --- /dev/null +++ b/docs/SIMPLEX.md @@ -0,0 +1,95 @@ +# SimpleX platform - motivation and comparison + +## Problems + +Existing chat platforms and protocols have some or all of the following problems: + +- Lack of privacy of the user profile and contacts (meta-data privacy). +- No protection (or only optional protection) of [E2EE][1] implementations from MITM attacks via provider. +- Unsolicited messages (spam and abuse). +- Lack of data ownership and protection. +- Complexity of usage for all non-centralized protocols to non-technical users. + +The concentration of the communication in a small number of centralized platforms makes resolving these problems quite difficult. + +## Proposed solution + +Proposed stack of protocols solves these problems by making both messages and contacts stored only on client devices, reducing the role of the servers to simple message relays that only require authorization of messages sent to the queues, but do NOT require user authentication - not only the messages but also the metadata is protected because users do not have any identifiers assigned to them - unlike with any other platforms. + +See [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design. + +## Why use SimpleX + +## SimpleX unique approach to privacy and security + +Everyone should care about privacy and security of their communications - even ordinary conversations can put you in danger. + +### Full privacy of your identity, profile, contacts and metadata + +**Unlike any other existing messaging platform, SimpleX has no identifiers assigned to the users** - it does not use phone numbers (like Signal or WhatsApp), domain-based addresses (like email, XMPP or Matrix), usernames (like Telegram), public keys or even random numbers (like all other messengers) to identify its users - we do not even know how many people use SimpleX. + +To deliver the messages instead of user identifiers that all other platforms use, SimpleX uses the addresses of unidirectional (simplex) message queues. Using SimpleX is like having a different email address or a phone number for each contact you have, but without the hassle of managing all these addresses. In the near future SimpleX apps will also change the message queues automatically, moving the conversations from one server to another, to provide even better privacy to the users. + +This approach protects the privacy of who are you communicating with, hiding it from SimpleX platform servers and from any observers. You can further improve your privacy by configuring your network access to connect to SimpleX servers via some overlay transport network, e.g. Tor. + +### The best protection against spam and abuse + +As you have no identifier on SimpleX platform, you cannot be contacted unless you share a one-time invitation link or an optional temporary user address. Even with the optional user addresses, while they can be used to send spam contact requests, you can change or completely delete it without losing any of your connections. + +### Complete ownership, control and security of your data + +SimpleX stores all user data on client devices, the messages are only held temporarily on SimpleX relay servers until they are received. + +We use portable database format that can be used on all supported devices - we will soon add the ability to export the chat database from the mobile app so it can be used on another device. + +Unlike servers of federated networks (email, XMPP or Matrix), SimpleX servers do not store user accounts, they simply relay messages to the recipients, protecting the privacy of both parties. There are no identifiers or encrypted messages in common between sent and received traffic of the server, thanks to the additional encryption layer for delivered messages. So if anybody is observing server traffic, they cannot easily determine who is communicating with whom (see [SimpleX whitepaper](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for the known traffic correlation attacks). + +### Users own SimpleX network + +You can use SimpleX with your own servers and still communicate with people using the servers that are pre-configured in the apps or any other SimpleX servers. + +SimpleX platform uses an open protocol and provides SDK to create chat bots, allowing implementation of services that users can interact with via SimpleX Chat apps – we are really looking forward to see what SimpleX services can be built. + +If you are considering developing with the SimpleX platform, whether for chat bot services for SimpleX app users or to integrate the SimpleX Chat library into your mobile apps, please get in touch for any advice and support. + +## Comparison with other protocols + +| | SimpleX chat | Signal, big platforms | XMPP, Matrix | P2P protocols | +| :--------------------------------------------- | :----------------: | :-------------------: | :-------------: | :-------------: | +| Requires user identifiers | No = private | Yes1 | Yes2 | Yes3 | +| Possibility of MITM | No = secure | Yes4 | Yes | Yes | +| Dependence on DNS | No = resilient | Yes | Yes | No | +| Single operator or network | No = decentralized | Yes | No | Yes5 | +| Central component or other network-wide attack | No = resilient | Yes | Yes2 | Yes6 | + +1. Usually based on a phone number, in some cases on usernames. +2. DNS based. +3. Public key or some other globally unique ID. +4. If operator’s servers are compromised. +5. While P2P networks and cryptocurrency-based networks are distributed, they are not decentralized - they operate as a single network, with a single namespace of user addresses. +6. P2P networks either have a central authority or the whole network can be compromised - see the next section. + +## Comparison with [P2P][9] messaging protocols + +There are several P2P chat/messaging protocols and implementations that aim to solve privacy and centralisation problem, but they have their own set of problems that makes them less reliable than the proposed design, more complex to implement and analyse and more vulnerable to attacks. + +1. [P2P][9] networks use some variant of [DHT][10] to route messages/requests through the network. DHT implementations have complex designs that have to balance reliability, delivery guarantee and latency. The proposed design has both better delivery guarantees and lower latency (the message is passed multiple times in parallel, through one node each time, using servers chosen by the recipient, while in P2P networks the message is passed through `O(log N)` nodes sequentially, using nodes chosen by the algorithm). + +2. The proposed design, unlike most P2P networks, has no global user identifiers of any kind, even temporary. + +3. P2P itself does not solve [MITM attack][2] problem, and most existing solutions do not use out-of-band messages for the initial key exchange. The proposed design uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange. + +4. P2P implementations can be blocked by some Internet providers (like [BitTorrent][11]). The proposed design is transport agnostic - it can work over standard web protocols, and the servers can be deployed on the same domains as the websites. + +5. All known P2P networks are likely to be vulnerable to [Sybil attack][12], because each node is discoverable, and the network operates as a whole. Known measures to reduce the probability of the Sybil attack either require a centralized component or expensive [proof of work][13]. The proposed design, on the opposite, has no server discoverability - servers are not connected, not known to each other and to all clients. The SimpleX network is fragmented and operates as multiple isolated connections. It makes network-wide attacks on SimpleX network impossible - even if some servers are compromised, other parts of the network can operate normally, and affected clients can switch to using other servers without losing contacts or messages. + +6. P2P networks are likely to be vulnerable to [DRDoS attack][14]. In the proposed design clients only relay traffic from known trusted connection and cannot be used to reflect and amplify the traffic in the whole network. + +[1]: https://en.wikipedia.org/wiki/End-to-end_encryption +[2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack +[9]: https://en.wikipedia.org/wiki/Peer-to-peer +[10]: https://en.wikipedia.org/wiki/Distributed_hash_table +[11]: https://en.wikipedia.org/wiki/BitTorrent +[12]: https://en.wikipedia.org/wiki/Sybil_attack +[13]: https://en.wikipedia.org/wiki/Proof_of_work +[14]: https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent diff --git a/message_queries.md b/docs/SQL.md similarity index 94% rename from message_queries.md rename to docs/SQL.md index 0f5c8d70ad..c422c53a31 100644 --- a/message_queries.md +++ b/docs/SQL.md @@ -1,4 +1,4 @@ -# Message queries +# Accessing message history via SQL queries You can run queries against `direct_messages`, `group_messages` and `all_messages` (or their simpler alternatives `direct_messages_plain`, `group_messages_plain` and `all_messages_plain`), for example: @@ -21,7 +21,7 @@ select * from all_messages_plain; -- files you offered for sending select * from direct_messages where msg_sent = 1 and chat_msg_event = 'x.file'; -- everything catherine sent related to cats -select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; +select * from direct_messages where msg_sent = 0 and contact = 'catherine' and msg_body like '%cats%'; -- all correspondence with alice in #team select * from group_messages where group_name = 'team' and contact = 'alice'; diff --git a/protocol/types.ts b/docs/protocol/types.ts similarity index 100% rename from protocol/types.ts rename to docs/protocol/types.ts diff --git a/rfcs/2022-01-26-mobile-app.md b/docs/rfcs/2022-01-26-mobile-app.md similarity index 100% rename from rfcs/2022-01-26-mobile-app.md rename to docs/rfcs/2022-01-26-mobile-app.md diff --git a/rfcs/2022-02-10-deduplicate-contact-requests.md b/docs/rfcs/2022-02-10-deduplicate-contact-requests.md similarity index 100% rename from rfcs/2022-02-10-deduplicate-contact-requests.md rename to docs/rfcs/2022-02-10-deduplicate-contact-requests.md diff --git a/rfcs/2022-02-24-servers-configuration.md b/docs/rfcs/2022-02-24-servers-configuration.md similarity index 100% rename from rfcs/2022-02-24-servers-configuration.md rename to docs/rfcs/2022-02-24-servers-configuration.md diff --git a/rfcs/2022-03-02-avatars.md b/docs/rfcs/2022-03-02-avatars.md similarity index 100% rename from rfcs/2022-03-02-avatars.md rename to docs/rfcs/2022-03-02-avatars.md diff --git a/rfcs/2022-03-02-number-chat-items.md b/docs/rfcs/2022-03-02-number-chat-items.md similarity index 100% rename from rfcs/2022-03-02-number-chat-items.md rename to docs/rfcs/2022-03-02-number-chat-items.md diff --git a/flake.nix b/flake.nix index ecd71e5f95..6e13c24a64 100644 --- a/flake.nix +++ b/flake.nix @@ -8,7 +8,7 @@ let systems = [ "x86_64-linux" "x86_64-darwin" "aarch64-linux" "aarch64-darwin" ]; in flake-utils.lib.eachSystem systems (system: let pkgs = haskellNix.legacyPackages.${system}; in - let drv = pkgs': pkgs'.haskell-nix.project { + let drv' = { extra-modules, pkgs', ... }: pkgs'.haskell-nix.project { compiler-nix-name = "ghc8107"; index-state = "2022-01-24T00:00:00Z"; # We need this, to specify we want the cabal project. @@ -18,15 +18,17 @@ name = "simplex-chat"; src = ./.; }; - sha256map = import ./sha256map.nix; + sha256map = import ./scripts/nix/sha256map.nix; modules = [{ - packages.direct-sqlite.patches = [ ./direct-sqlite-2.3.26.patch ]; - packages.entropy.patches = [ ./entropy.patch ]; + packages.direct-sqlite.patches = [ ./scripts/nix/direct-sqlite-2.3.26.patch ]; + packages.entropy.patches = [ ./scripts/nix/entropy.patch ]; } ({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) { packages.simplex-chat.components.library.ghcOptions = [ "-pie" ]; - })]; + })] ++ extra-modules; }; in + # by defualt we don't need to pass extra-modules. + let drv = pkgs': drv' { extra-modules = []; inherit pkgs'; }; in # This will package up all *.a in $out into a pkg.zip that can # be downloaded from hydra. let withHydraLibPkg = pkg: pkg.overrideAttrs (old: { @@ -211,6 +213,33 @@ }; }; "aarch64-darwin" = { + # this is the aarch64-darwin iOS build (to be patched with mac2ios) + "aarch64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swift = true; }]; } ).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; + # we need threaded here, otherwise all the queing logic doesn't work properly. + # for iOS we also use -staticlib, to get one rolled up library. + # still needs mac2ios patching of the archives. + ghcOptions = [ "-staticlib" "-threaded" "-DIOS" ]; + postInstall = '' + ${pkgs.tree}/bin/tree $out + mkdir -p $out/_pkg + # copy over includes, we might want those, but maybe not. + # cp -r $out/lib/*/*/include $out/_pkg/ + # find the libHS...ghc-X.Y.Z.a static library; this is the + # rolled up one with all dependencies included. + find ./dist -name "libHS*.a" -exec cp {} $out/_pkg \; + find ${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib -name "*.a" -exec cp {} $out/_pkg \; + find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; + # There is no static libc + ${pkgs.tree}/bin/tree $out/_pkg + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-aarch64-swift-json.zip *) + rm -fR $out/_pkg + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }; + # This is the aarch64-darwin build with tagged JSON format (for Mac & Flutter) "aarch64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # we need threaded here, otherwise all the queing logic doesn't work properly. @@ -229,7 +258,7 @@ find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; # There is no static libc ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-aarch64.zip *) + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-aarch64-tagged-json.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ @@ -238,6 +267,33 @@ }; }; "x86_64-darwin" = { + # this is the aarch64-darwin iOS build (to be patched with mac2ios) + "x86_64-darwin-ios:lib:simplex-chat" = (drv' { pkgs' = pkgs; extra-modules = [{ packages.simplexmq.flags.swift = true; }]; } ).simplex-chat.components.library.override { + smallAddressSpace = true; enableShared = false; + # we need threaded here, otherwise all the queing logic doesn't work properly. + # for iOS we also use -staticlib, to get one rolled up library. + # still needs mac2ios patching of the archives. + ghcOptions = [ "-staticlib" "-threaded" "-DIOS" ]; + postInstall = '' + ${pkgs.tree}/bin/tree $out + mkdir -p $out/_pkg + # copy over includes, we might want those, but maybe not. + # cp -r $out/lib/*/*/include $out/_pkg/ + # find the libHS...ghc-X.Y.Z.a static library; this is the + # rolled up one with all dependencies included. + find ./dist -name "libHS*.a" -exec cp {} $out/_pkg \; + find ${pkgs.libffi.overrideAttrs (old: { dontDisableStatic = true; })}/lib -name "*.a" -exec cp {} $out/_pkg \; + find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; + # There is no static libc + ${pkgs.tree}/bin/tree $out/_pkg + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-x86_64-swift-json.zip *) + rm -fR $out/_pkg + mkdir -p $out/nix-support + echo "file binary-dist \"$(echo $out/*.zip)\"" \ + > $out/nix-support/hydra-build-products + ''; + }; + # This is the aarch64-darwin build with tagged JSON format (for Mac & Flutter) "x86_64-darwin:lib:simplex-chat" = (drv pkgs).simplex-chat.components.library.override { smallAddressSpace = true; enableShared = false; # we need threaded here, otherwise all the queing logic doesn't work properly. @@ -256,7 +312,7 @@ find ${pkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \; # There is no static libc ${pkgs.tree}/bin/tree $out/_pkg - (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-x86_64.zip *) + (cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg-ios-x86_64-tagged-json.zip *) rm -fR $out/_pkg mkdir -p $out/nix-support echo "file binary-dist \"$(echo $out/*.zip)\"" \ @@ -273,7 +329,7 @@ name = "update-sha256map"; runtimeInputs = [ pkgs.nix-prefetch-git pkgs.jq pkgs.gawk ]; text = '' - gawk -f update-sha256.awk cabal.project > sha256map.nix + gawk -f ./scripts/nix/update-sha256.awk cabal.project > ./scripts/nix/sha256map.nix ''; }; in pkgs.mkShell { diff --git a/package.yaml b/package.yaml index f0917c04aa..835bf19a9f 100644 --- a/package.yaml +++ b/package.yaml @@ -1,5 +1,5 @@ name: simplex-chat -version: 1.5.0 +version: 1.6.0 #synopsis: #description: homepage: https://github.com/simplex-chat/simplex-chat#readme diff --git a/scripts/ios/prepare-x86_64.sh b/scripts/ios/prepare-x86_64.sh new file mode 100755 index 0000000000..534365cb59 --- /dev/null +++ b/scripts/ios/prepare-x86_64.sh @@ -0,0 +1,13 @@ +#!/bin/sh + +# the binaries folders should be in ~/Downloads folder +rm -rf ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +mkdir -p ./apps/ios/Libraries/mac-aarch64 ./apps/ios/Libraries/mac-x86_64 ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +cp ~/Downloads/pkg-ios-aarch64-swift-json/* ./apps/ios/Libraries/mac-aarch64 +cp ~/Downloads/pkg-ios-x86_64-swift-json/* ./apps/ios/Libraries/mac-x86_64 +chmod +w ./apps/ios/Libraries/mac-aarch64/* +chmod +w ./apps/ios/Libraries/mac-x86_64/* +cp ./apps/ios/Libraries/mac-aarch64/* ./apps/ios/Libraries/ios +cp ./apps/ios/Libraries/mac-x86_64/* ./apps/ios/Libraries/sim +for f in ./apps/ios/Libraries/ios/*; do mac2ios $f; done | wc -l +for f in ./apps/ios/Libraries/sim/*; do mac2ios -s $f; done | wc -l diff --git a/scripts/ios/prepare.sh b/scripts/ios/prepare.sh new file mode 100755 index 0000000000..3e4157b4ca --- /dev/null +++ b/scripts/ios/prepare.sh @@ -0,0 +1,11 @@ +#!/bin/sh + +# the binaries folder should be in ~/Downloads folder +rm -rf ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +mkdir -p ./apps/ios/Libraries/mac ./apps/ios/Libraries/ios ./apps/ios/Libraries/sim +cp ~/Downloads/pkg-ios-aarch64-swift-json/* ./apps/ios/Libraries/mac +chmod +w ./apps/ios/Libraries/mac/* +cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/ios +cp ./apps/ios/Libraries/mac/* ./apps/ios/Libraries/sim +for f in ./apps/ios/Libraries/ios/*; do mac2ios $f; done | wc -l +for f in ./apps/ios/Libraries/sim/*; do mac2ios -s $f; done | wc -l diff --git a/message_views.sql b/scripts/message_views.sql similarity index 100% rename from message_views.sql rename to scripts/message_views.sql diff --git a/direct-sqlite-2.3.26.patch b/scripts/nix/direct-sqlite-2.3.26.patch similarity index 100% rename from direct-sqlite-2.3.26.patch rename to scripts/nix/direct-sqlite-2.3.26.patch diff --git a/entropy.patch b/scripts/nix/entropy.patch similarity index 100% rename from entropy.patch rename to scripts/nix/entropy.patch diff --git a/sha256map.nix b/scripts/nix/sha256map.nix similarity index 100% rename from sha256map.nix rename to scripts/nix/sha256map.nix diff --git a/update-sha256.awk b/scripts/nix/update-sha256.awk similarity index 100% rename from update-sha256.awk rename to scripts/nix/update-sha256.awk diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 884a7693c8..65c7ea92e2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -5,7 +5,7 @@ cabal-version: 1.12 -- see: https://github.com/sol/hpack name: simplex-chat -version: 1.5.0 +version: 1.6.0 category: Web, System, Services, Cryptography homepage: https://github.com/simplex-chat/simplex-chat#readme author: simplex.chat @@ -22,6 +22,7 @@ library Simplex.Chat Simplex.Chat.Bot Simplex.Chat.Controller + Simplex.Chat.Core Simplex.Chat.Help Simplex.Chat.Markdown Simplex.Chat.Messages @@ -34,6 +35,7 @@ library Simplex.Chat.Migrations.M20220302_profile_images Simplex.Chat.Migrations.M20220304_msg_quotes Simplex.Chat.Migrations.M20220321_chat_item_edited + Simplex.Chat.Migrations.M20220404_files_status_fields Simplex.Chat.Mobile Simplex.Chat.Options Simplex.Chat.Protocol @@ -200,6 +202,7 @@ test-suite simplex-chat-test MarkdownTests MobileTests ProtocolTests + SchemaDump Paths_simplex_chat hs-source-dirs: tests diff --git a/simplex.md b/simplex.md deleted file mode 100644 index 6dc22096d1..0000000000 --- a/simplex.md +++ /dev/null @@ -1,84 +0,0 @@ -# Federated chat system with [E2EE][1] and low risk of [MITM attack][2] - -## Problems - -Existing chat platforms and protocols have some or all of the following problems: - -- Lack of privacy of the user profile and connections (meta-data privacy). -- No protection (or only optional protection) of [E2EE][1] implementations from MITM attacks. -- Unsolicited messages (spam and abuse). -- Lack of data ownership and protection. -- Complexity of usage for all non-centralized protocols to non-technical users. - -The concentration of the communication in a small number of centralized platforms makes resolving these problems quite difficult. - -## Proposed solution - -Proposed stack of protocols solves these and other problems by making both messages and contacts accessible only on client devices, reducing the role of the servers to simple message brokers that only require authorization of messages sent to the queues, but do NOT require user authentication - not only the messages but also the metadata is protected. - -See [SMP protocol][6] and [SMP agent protocol][8]. - -## Comparison with other protocols - -| | SimpleX chat | Signal, big platforms | XMPP, Matrix | P2P protocols | -|:-------- |:------------:|:---------------------:|:------------:|:-------------:| -| Requires global identity | No = private | Yes1 | Yes2 | Yes3 | -| Possibility of MITM | No = secure | Yes4 | Yes | Yes | -| Dependence on DNS | No = resilient | Yes | Yes | No | -| Federation | Yes | No | Yes | No5 | -| Central component or other network-wide attack | No = resilient | Yes | Yes2 | Yes6 | - -1. Usually based on a phone number, in some cases on usernames. -2. DNS based. -3. Public key or some other globally unique ID. -4. If operator’s servers are compromised. -5. While P2P networks are distributed, they are not federated - they operate as a single network. -6. P2P networks either have a central authority or the whole network can be compromised - see the next section. - -## Comparison with [P2P][9] messaging protocols - -There are several P2P chat/messaging protocols and implementations that aim to solve privacy and centralisation problem, but they have their own set of problems that makes them less reliable than the proposed chat system design, more complex to implement and analyse and more vulnerable to attacks. - -1. [P2P][9] networks either have some centralized component, which makes them highly vulnerable, or, more commonly, use some variant of [DHT][10] to route messages/requests through the network. DHT implementations have complex designs that have to balance reliability, delivery guarantee and latency, and also have some other problems. The proposed chat system design has both higher delivery guarantee and low latency (the message is passed multiple times in parallel, through one node each time, using servers chosen by the recipient, while in P2P networks the message is passed through `O(log N)` nodes sequentially, using nodes chosen by the algorithm). - -2. The proposed design, unlike most P2P networks, has no global identity of any form, even temporary. - -3. P2P itself does not solve [MITM attack][2] problem, but most existing solutions do not use out-of-band messages for the initial key exchange. The proposed design uses out-of-band messages or, in some cases, pre-existing secure and trusted connections for the initial key exchange. - -4. P2P implementations can be blocked by some Internet providers (like [BitTorrent][11]). The proposed design is transport agnostic - it can work over standard web protocols, and the servers can be deployed on the same domains as the websites. - -5. All known P2P networks are likely to be vulnerable to [Sybil attack][12], because each node is discoverable, and the network operates as a whole. Known measures to reduce the probability of the Sybil attack either require a vulnerable centralized component or expensive [proof of work][13]. The proposed design, on the opposite, has no server discoverability - servers are not connected, not known to each other and to all clients. The chat network is fragmented and operates as multiple isolated connections. It makes Sybil attack on the whole simplex messaging network impossible - even if some servers are compromised, other parts of the network can operate normally, and affected clients can always switch to using other servers without losing contacts or messages. - -6. P2P networks are likely to be vulnerable to [DRDoS attack][14]. In the proposed design clients only relay traffic from known trusted connection and cannot be used to reflect and amplify the traffic in the whole network. - -## Network features - -- No user identity known to system servers - no phone numbers, user names and no DNS are needed to authorize users to the network. -- Each user can be connected to multiple servers to ensure message delivery, even if some of the servers are compromised. -- No single server in the system has visibility of all connections or messages of any user, as user profiles are identified by multiple rotating public keys, using separate key for each profile connection. -- Uses standard asymmetric cryptographic protocols, so that system users can create independent server and client implementations complying with the protocols. -- Open-source server implementations that can be easily deployed by any user with minimal technical expertise (e.g. on Heroku via web UI). -- Open-source client implementations so that system users can independently assess system security model. -- Only client applications store user profiles, contacts of other user profiles, messages; servers do NOT have access to any of this information and (unless compromised) do NOT store encrypted messages or any logs. -- Multiple client applications and devices can be used by each user profile to communicate and to share connections and message history - the devices are not known to the servers. -- Initial key exchange and establishing connections between user profiles is done by sharing the invitation (e.g. QR code via any independent communication channel (or directly via screen and camera), system servers are NOT used for key exchange - to reduce risk of key substitution in [MITM attack][2]. QR code contains the connection-specific public key and other information needed to establish the connection. -- Connections between users can be established via shared trusted connections to simplify key exchange. -- Servers do NOT communicate with each other, they only communicate with client applications. -- Unique public key is used for each user profile connection in order to: - - reduce the risk of attacker posing as user's connection; - - avoid exposing all user connections to the servers. -- Unique public key is used to identify each connection participant to each server. -- Public keys used between connections are regularly rotated to prevent decryption of the full message history ([forward secrecy][4]) in case when some servers or middlemen preserve message history and the current key is compromised. -- Users can repeat key exchange using QR code and alternative channel at any point to increase communication security and trust. - -[1]: https://en.wikipedia.org/wiki/End-to-end_encryption -[2]: https://en.wikipedia.org/wiki/Man-in-the-middle_attack -[4]: https://en.wikipedia.org/wiki/Forward_secrecy -[6]: https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md -[8]: https://github.com/simplex-chat/simplexmq/blob/master/protocol/agent-protocol.md -[9]: https://en.wikipedia.org/wiki/Peer-to-peer -[10]: https://en.wikipedia.org/wiki/Distributed_hash_table -[11]: https://en.wikipedia.org/wiki/BitTorrent -[12]: https://en.wikipedia.org/wiki/Sybil_attack -[13]: https://en.wikipedia.org/wiki/Proof_of_work -[14]: https://www.usenix.org/conference/woot15/workshop-program/presentation/p2p-file-sharing-hell-exploiting-bittorrent diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index bbe08d5934..49c915aeb6 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -64,7 +64,7 @@ import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout) import Text.Read (readMaybe) import UnliftIO.Async import UnliftIO.Concurrent (forkIO, threadDelay) -import UnliftIO.Directory (doesDirectoryExist, doesFileExist, getFileSize, getHomeDirectory, getTemporaryDirectory) +import UnliftIO.Directory import qualified UnliftIO.Exception as E import UnliftIO.IO (hClose, hSeek, hTell) import UnliftIO.STM @@ -100,10 +100,11 @@ defaultSMPServers = logCfg :: LogConfig logCfg = LogConfig {lc_file = Nothing, lc_stderr = True} -newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> (Notification -> IO ()) -> IO ChatController -newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} ChatOpts {dbFilePrefix, smpServers, logConnections} sendNotification = do +newChatController :: SQLiteStore -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController +newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} ChatOpts {dbFilePrefix, smpServers, logConnections} sendToast = do let f = chatStoreFile dbFilePrefix - let config = cfg {subscriptionEvents = logConnections} + config = cfg {subscriptionEvents = logConnections} + sendNotification = fromMaybe (const $ pure ()) sendToast activeTo <- newTVarIO ActiveNone firstTime <- not <$> doesFileExist f currentUser <- newTVarIO user @@ -117,7 +118,8 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch chatLock <- newTMVarIO () sndFiles <- newTVarIO M.empty rcvFiles <- newTVarIO M.empty - pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification} + filesFolder <- newTVarIO Nothing + pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification, filesFolder} where resolveServers :: IO (NonEmpty SMPServer) resolveServers = case user of @@ -146,10 +148,13 @@ withLock lock = (atomically $ putTMVar lock ()) execChatCommand :: (MonadUnliftIO m, MonadReader ChatController m) => ByteString -> m ChatResponse -execChatCommand s = case parseAll chatCommandP $ B.dropWhileEnd isSpace s of +execChatCommand s = case parseChatCommand s of Left e -> pure $ chatCmdError e Right cmd -> either CRChatCmdError id <$> runExceptT (processChatCommand cmd) +parseChatCommand :: ByteString -> Either String ChatCommand +parseChatCommand = parseAll chatCommandP . B.dropWhileEnd isSpace + toView :: ChatMonad m => ChatResponse -> m () toView event = do q <- asks outputQ @@ -168,53 +173,100 @@ processChatCommand = \case asks agentAsync >>= readTVarIO >>= \case Just _ -> pure CRChatRunning _ -> startChatController user $> CRChatStarted + SetFilesFolder filesFolder' -> withUser $ \_ -> do + createDirectoryIfMissing True filesFolder' + ff <- asks filesFolder + atomically . writeTVar ff $ Just filesFolder' + pure CRCmdOk APIGetChats -> CRApiChats <$> withUser (\user -> withStore (`getChatPreviews` user)) APIGetChat cType cId pagination -> withUser $ \user -> case cType of CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination) CTGroup -> CRApiChat . AChat SCTGroup <$> withStore (\st -> getGroupChat st user cId pagination) CTContactRequest -> pure $ chatCmdError "not implemented" APIGetChatItems _pagination -> pure $ chatCmdError "not implemented" - APISendMessage cType chatId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of + APISendMessage cType chatId file_ quotedItemId_ mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - ct <- withStore $ \st -> getContact st userId chatId - sendNewMsg user ct (MCSimple mc) mc Nothing - CTGroup -> do - group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId - unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - sendNewGroupMsg user group (MCSimple mc) mc Nothing - CTContactRequest -> pure $ chatCmdError "not supported" - APISendMessageQuote cType chatId quotedItemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of - CTDirect -> do - (ct, qci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId quotedItemId - case qci of - CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do - case ciContent of - CISndMsgContent qmc -> send_ CIQDirectSnd True qmc - CIRcvMsgContent qmc -> send_ CIQDirectRcv False qmc - _ -> throwChatError CEInvalidQuote + ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId + (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer ct + (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ + msg <- sendDirectContactMessage ct (XMsgNew msgContainer) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile_ quotedItem_ + setActive $ ActiveC c + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + where + setupSndFileTransfer :: Contact -> m (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer ct = case file_ of + Nothing -> pure Nothing + Just file -> do + (fileSize, chSize) <- checkSndFile file + (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) + let fileName = takeFileName file + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq} + fileId <- withStore $ \st -> createSndFileTransfer st userId ct file fileInvitation agentConnId chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} + pure $ Just (fileInvitation, ciFile) + prepareMsg :: Maybe FileInvitation -> m (MsgContainer, Maybe (CIQuote 'CTDirect)) + prepareMsg fileInvitation_ = case quotedItemId_ of + Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) + Just quotedItemId -> do + CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + withStore $ \st -> getDirectChatItem st userId chatId quotedItemId + (origQmc, qd, sent) <- quoteData ciContent + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} + qmc = quoteContent origQmc mc + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where - send_ :: CIQDirection 'CTDirect -> Bool -> MsgContent -> m ChatResponse - send_ chatDir sent qmc = - let quotedItem = CIQuote {chatDir, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} - msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing} - in sendNewMsg user ct (MCQuote QuotedMsg {msgRef, content = qmc} mc) mc (Just quotedItem) + quoteData :: CIContent d -> m (MsgContent, CIQDirection 'CTDirect, Bool) + quoteData (CISndMsgContent qmc) = pure (qmc, CIQDirectSnd, True) + quoteData (CIRcvMsgContent qmc) = pure (qmc, CIQDirectRcv, False) + quoteData _ = throwChatError CEInvalidQuote CTGroup -> do - group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId + Group gInfo@GroupInfo {membership, localDisplayName = gName} ms <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - qci <- withStore $ \st -> getGroupChatItem st user chatId quotedItemId - case qci of - CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do - case (ciContent, chatDir) of - (CISndMsgContent qmc, _) -> send_ CIQGroupSnd True membership qmc - (CIRcvMsgContent qmc, CIGroupRcv m) -> send_ (CIQGroupRcv $ Just m) False m qmc - _ -> throwChatError CEInvalidQuote + (fileInvitation_, ciFile_) <- unzipMaybe <$> setupSndFileTransfer gInfo + (msgContainer, quotedItem_) <- prepareMsg fileInvitation_ membership + msg <- sendGroupMessage gInfo ms (XMsgNew msgContainer) + ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent mc) ciFile_ quotedItem_ + setActive $ ActiveG gName + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + where + setupSndFileTransfer :: GroupInfo -> m (Maybe (FileInvitation, CIFile 'MDSnd)) + setupSndFileTransfer gInfo = case file_ of + Nothing -> pure Nothing + Just file -> do + (fileSize, chSize) <- checkSndFile file + let fileName = takeFileName file + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} + fileId <- withStore $ \st -> createSndGroupFileTransferV2 st userId gInfo file fileInvitation chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just file, fileStatus = CIFSSndStored} + pure $ Just (fileInvitation, ciFile) + prepareMsg :: Maybe FileInvitation -> GroupMember -> m (MsgContainer, Maybe (CIQuote 'CTGroup)) + prepareMsg fileInvitation_ membership = case quotedItemId_ of + Nothing -> pure (MCSimple (ExtMsgContent mc fileInvitation_), Nothing) + Just quotedItemId -> do + CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} <- + withStore $ \st -> getGroupChatItem st user chatId quotedItemId + (origQmc, qd, sent, GroupMember {memberId}) <- quoteData ciContent chatDir membership + let msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} + qmc = quoteContent origQmc mc + quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText} + pure (MCQuote QuotedMsg {msgRef, content = qmc} (ExtMsgContent mc fileInvitation_), Just quotedItem) where - send_ :: CIQDirection 'CTGroup -> Bool -> GroupMember -> MsgContent -> m ChatResponse - send_ qd sent GroupMember {memberId} content = - let quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content, formattedText} - msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId} - in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} mc) mc (Just quotedItem) + quoteData :: CIContent d -> CIDirection 'CTGroup d -> GroupMember -> m (MsgContent, CIQDirection 'CTGroup, Bool, GroupMember) + quoteData (CISndMsgContent qmc) CIGroupSnd membership' = pure (qmc, CIQGroupSnd, True, membership') + quoteData (CIRcvMsgContent qmc) (CIGroupRcv m) _ = pure (qmc, CIQGroupRcv $ Just m, False, m) + quoteData _ _ _ = throwChatError CEInvalidQuote CTContactRequest -> pure $ chatCmdError "not supported" + where + quoteContent qmc = \case + MCText _ -> qmc + _ -> MCText $ msgContentText qmc + unzipMaybe :: Maybe (a, b) -> (Maybe a, Maybe b) + unzipMaybe t = (fst <$> t, snd <$> t) + -- TODO discontinue + APISendMessageQuote cType chatId quotedItemId mc -> + processChatCommand $ APISendMessage cType chatId Nothing (Just quotedItemId) mc APIUpdateChatItem cType chatId itemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do (ct@Contact {contactId, localDisplayName = c}, ci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId @@ -245,13 +297,15 @@ processChatCommand = \case CTContactRequest -> pure $ chatCmdError "not supported" APIDeleteChatItem cType chatId itemId mode -> withUser $ \user@User {userId} -> withChatLock $ case cType of CTDirect -> do - (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId + (ct@Contact {localDisplayName = c}, CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file}) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do + deleteFile userId file toCi <- withStore $ \st -> deleteDirectChatItemInternal st userId ct itemId pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendDirectContactMessage ct (XMsgDel itemSharedMId) + deleteFile userId file toCi <- withStore $ \st -> deleteDirectChatItemSndBroadcast st userId ct itemId msgId setActive $ ActiveC c pure $ CRChatItemDeleted (AChatItem SCTDirect msgDir (DirectChat ct) deletedItem) toCi @@ -259,18 +313,27 @@ processChatCommand = \case CTGroup -> do Group gInfo@GroupInfo {localDisplayName = gName, membership} ms <- withStore $ \st -> getGroup st user chatId unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved - CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}} <- withStore $ \st -> getGroupChatItem st user chatId itemId + CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemSharedMsgId}, file} <- withStore $ \st -> getGroupChatItem st user chatId itemId case (mode, msgDir, itemSharedMsgId) of (CIDMInternal, _, _) -> do + deleteFile userId file toCi <- withStore $ \st -> deleteGroupChatItemInternal st user gInfo itemId pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, SMDSnd, Just itemSharedMId) -> do SndMessage {msgId} <- sendGroupMessage gInfo ms (XMsgDel itemSharedMId) + deleteFile userId file toCi <- withStore $ \st -> deleteGroupChatItemSndBroadcast st user gInfo itemId msgId setActive $ ActiveG gName pure $ CRChatItemDeleted (AChatItem SCTGroup msgDir (GroupChat gInfo) deletedItem) toCi (CIDMBroadcast, _, _) -> throwChatError CEInvalidChatItemDelete CTContactRequest -> pure $ chatCmdError "not supported" + where + deleteFile :: MsgDirectionI d => UserId -> Maybe (CIFile d) -> m () + deleteFile userId file = + forM_ file $ \CIFile {fileId, filePath, fileStatus} -> do + cancelFiles userId [(fileId, AFS msgDirection fileStatus)] + withFilesFolder $ \filesFolder -> + deleteFiles filesFolder [filePath] APIChatRead cType chatId fromToIds -> withChatLock $ case cType of CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk CTGroup -> withStore (\st -> updateGroupChatItemsRead st chatId fromToIds) $> CRCmdOk @@ -280,8 +343,12 @@ processChatCommand = \case ct@Contact {localDisplayName} <- withStore $ \st -> getContact st userId chatId withStore (\st -> getContactGroupNames st userId ct) >>= \case [] -> do + files <- withStore $ \st -> getContactFiles st userId ct conns <- withStore $ \st -> getContactConnections st userId ct withChatLock . procCmd $ do + cancelFiles userId (map (\(fId, fStatus, _) -> (fId, fStatus)) files) + withFilesFolder $ \filesFolder -> do + deleteFiles filesFolder (map (\(_, _, fPath) -> fPath) files) withAgent $ \a -> forM_ conns $ \conn -> deleteConnection a (aConnId conn) `catchError` \(_ :: AgentErrorType) -> pure () withStore $ \st -> deleteContact st userId ct @@ -301,6 +368,7 @@ processChatCommand = \case withAgent $ \a -> rejectContact a connId invId pure $ CRContactRequestRejected cReq APIUpdateProfile profile -> withUser (`updateProfile` profile) + APIParseMarkdown text -> pure . CRApiParsedMarkdown $ parseMaybeMarkdownList text GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user)) SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do withStore $ \st -> overwriteSMPServers st user smpServers @@ -349,21 +417,25 @@ processChatCommand = \case SendMessage cName msg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTDirect contactId mc + processChatCommand $ APISendMessage CTDirect contactId Nothing Nothing mc SendMessageBroadcast msg -> withUser $ \user -> do contacts <- withStore (`getUserContacts` user) withChatLock . procCmd $ do let mc = MCText $ safeDecodeUtf8 msg cts = filter isReady contacts forM_ cts $ \ct -> - void (sendDirectChatItem user ct (XMsgNew $ MCSimple mc) (CISndMsgContent mc) Nothing) + void + ( do + sndMsg <- sendDirectContactMessage ct (XMsgNew $ MCSimple (ExtMsgContent mc Nothing)) + saveSndChatItem user (CDDirectSnd ct) sndMsg (CISndMsgContent mc) Nothing Nothing + ) `catchError` (toView . CRChatError) CRBroadcastSent mc (length cts) <$> liftIO getZonedTime SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessageQuote CTDirect contactId quotedItemId mc + processChatCommand $ APISendMessage CTDirect contactId Nothing (Just quotedItemId) mc DeleteMessage cName deletedMsg -> withUser $ \User {userId} -> do contactId <- withStore $ \st -> getContactIdByName st userId cName deletedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId SMDSnd (safeDecodeUtf8 deletedMsg) @@ -447,12 +519,12 @@ processChatCommand = \case SendGroupMessage gName msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessage CTGroup groupId mc + processChatCommand $ APISendMessage CTGroup groupId Nothing Nothing mc SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do groupId <- withStore $ \st -> getGroupIdByName st user gName quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg) let mc = MCText $ safeDecodeUtf8 msg - processChatCommand $ APISendMessageQuote CTGroup groupId quotedItemId mc + processChatCommand $ APISendMessage CTGroup groupId Nothing (Just quotedItemId) mc DeleteGroupMessage gName deletedMsg -> withUser $ \user@User {localDisplayName} -> do groupId <- withStore $ \st -> getGroupIdByName st user gName deletedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 deletedMsg) @@ -462,57 +534,73 @@ processChatCommand = \case editedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId (Just localDisplayName) (safeDecodeUtf8 editedMsg) let mc = MCText $ safeDecodeUtf8 msg processChatCommand $ APIUpdateChatItem CTGroup groupId editedItemId mc + -- old file protocol + -- SendFile cName f -> withUser $ \User {userId} -> do + -- contactId <- withStore $ \st -> getContactIdByName st userId cName + -- processChatCommand $ APISendMessage CTDirect contactId (Just f) Nothing (MCText "") + -- TODO replace with code above when switching from XFile SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do (fileSize, chSize) <- checkSndFile f contact <- withStore $ \st -> getContactByName st userId cName - (agentConnId, connReq) <- withAgent (`createConnection` SCMInvitation) - let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = ACR SCMInvitation connReq} - SndFileTransfer {fileId} <- withStore $ \st -> + (agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) + let fileName = takeFileName f + fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq = Just fileConnReq} + fileId <- withStore $ \st -> createSndFileTransfer st userId contact f fileInv agentConnId chSize - ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing + msg <- sendDirectContactMessage contact (XFile fileInv) + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} + ci <- saveSndChatItem user (CDDirectSnd contact) msg (CISndMsgContent $ MCText "") (Just ciFile) Nothing withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci setActive $ ActiveC cName pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci - SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do + -- new file protocol (not used for direct files) + SendFileInv cName f -> withUser $ \user@User {userId} -> withChatLock $ do + ct <- withStore $ \st -> getContactByName st userId cName (fileSize, chSize) <- checkSndFile f + let fileName = takeFileName f + fileInvitation = FileInvitation {fileName, fileSize, fileConnReq = Nothing} + fileId <- withStore $ \st -> createSndFileTransferV2 st userId ct f fileInvitation chSize + let mc = MCText "" + ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} + msg <- sendDirectContactMessage ct (XMsgNew (MCSimple (ExtMsgContent mc (Just fileInvitation)))) + ci <- saveSndChatItem user (CDDirectSnd ct) msg (CISndMsgContent mc) ciFile Nothing + setActive $ ActiveC cName + pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci + -- old file protocol + -- TODO discontinue + SendGroupFile gName f -> withUser $ \user@User {userId} -> withChatLock $ do Group gInfo@GroupInfo {groupId, membership} members <- withStore $ \st -> getGroupByName st user gName unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved + (fileSize, chSize) <- checkSndFile f let fileName = takeFileName f ms <- forM (filter memberActive members) $ \m -> do - (connId, connReq) <- withAgent (`createConnection` SCMInvitation) - pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = ACR SCMInvitation connReq}) + (connId, fileConnReq) <- withAgent (`createConnection` SCMInvitation) + pure (m, connId, FileInvitation {fileName, fileSize, fileConnReq = Just fileConnReq}) fileId <- withStore $ \st -> createSndGroupFileTransfer st userId gInfo ms f fileSize chSize - -- TODO sendGroupChatItem - same file invitation to all - forM_ ms $ \(m, _, fileInv) -> - traverse (\conn -> sendDirectMessage conn (XFile fileInv) (GroupId groupId)) $ memberConn m + forM_ ms $ \(m, _, fileInvitation) -> + traverse (\conn -> sendDirectMessage conn (XFile fileInvitation) (GroupId groupId)) $ memberConn m setActive $ ActiveG gName -- this is a hack as we have multiple direct messages instead of one per group let msg = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = ""} - ciContent = CISndFileInvitation fileId f - cItem@ChatItem {meta = CIMeta {itemId}} <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent Nothing - withStore $ \st -> updateFileTransferChatItemId st fileId itemId - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem - ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do - ft@RcvFileTransfer {fileInvitation = FileInvitation {fileName, fileConnReq = ACR _ fileConnReq}, fileStatus} <- withStore $ \st -> getRcvFileTransfer st userId fileId - unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fileName + ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Just f, fileStatus = CIFSSndStored} + ci <- saveSndChatItem user (CDGroupSnd gInfo) msg (CISndMsgContent $ MCText "") ciFile Nothing + pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci + -- new file protocol + SendGroupFileInv gName f -> withUser $ \user -> do + groupId <- withStore $ \st -> getGroupIdByName st user gName + processChatCommand $ APISendMessage CTGroup groupId (Just f) Nothing (MCText "") + ReceiveFile fileId filePath_ -> withUser $ \user@User {userId} -> withChatLock . procCmd $ do - tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XFileAcpt fileName) >>= \case - Right agentConnId -> do - filePath <- getRcvFilePath fileId filePath_ fileName - withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath - pure $ CRRcvFileAccepted ft filePath - Left (ChatErrorAgent (SMP SMP.AUTH)) -> pure $ CRRcvFileAcceptedSndCancelled ft - Left (ChatErrorAgent (CONN DUPLICATE)) -> pure $ CRRcvFileAcceptedSndCancelled ft - Left e -> throwError e + ft <- withStore $ \st -> getRcvFileTransfer st userId fileId + (CRRcvFileAccepted ft <$> acceptFileReceive user ft filePath_) `catchError` processError ft + where + processError ft = \case + ChatErrorAgent (SMP SMP.AUTH) -> pure $ CRRcvFileAcceptedSndCancelled ft + ChatErrorAgent (CONN DUPLICATE) -> pure $ CRRcvFileAcceptedSndCancelled ft + e -> throwError e CancelFile fileId -> withUser $ \User {userId} -> do - ft' <- withStore (\st -> getFileTransfer st userId fileId) - withChatLock . procCmd $ case ft' of - FTSnd fts -> do - forM_ fts $ \ft -> cancelSndFileTransfer ft - pure $ CRSndGroupFileCancelled fts - FTRcv ft -> do - cancelRcvFileTransfer ft - pure $ CRRcvFileCancelled ft + ft <- withStore (\st -> getFileTransfer st userId fileId) + withChatLock . procCmd $ cancelFile userId fileId ft FileStatus fileId -> CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId) ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile @@ -552,22 +640,15 @@ processChatCommand = \case connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId) withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId pure CRSentInvitation - sendNewMsg user ct@Contact {localDisplayName = c} msgContainer mc quotedItem = do - ci <- sendDirectChatItem user ct (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem - setActive $ ActiveC c - pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci - sendNewGroupMsg user g@(Group gInfo@GroupInfo {localDisplayName = gName} _) msgContainer mc quotedItem = do - ci <- sendGroupChatItem user g (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem - setActive $ ActiveG gName - pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci contactMember :: Contact -> [GroupMember] -> Maybe GroupMember contactMember Contact {contactId} = find $ \GroupMember {memberContactId = cId, memberStatus = s} -> cId == Just contactId && s /= GSMemRemoved && s /= GSMemLeft checkSndFile :: FilePath -> m (Integer, Integer) checkSndFile f = do - unlessM (doesFileExist f) . throwChatError $ CEFileNotFound f - (,) <$> getFileSize f <*> asks (fileChunkSize . config) + fsFilePath <- toFSFilePath f + unlessM (doesFileExist fsFilePath) . throwChatError $ CEFileNotFound f + (,) <$> getFileSize fsFilePath <*> asks (fileChunkSize . config) updateProfile :: User -> Profile -> m ChatResponse updateProfile user@User {profile = p} p'@Profile {displayName} | p' == p = pure CRUserProfileNoChange @@ -584,17 +665,105 @@ processChatCommand = \case isReady ct = let s = connStatus $ activeConn (ct :: Contact) in s == ConnReady || s == ConnSndReady - getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath - getRcvFilePath fileId filePath fileName = case filePath of - Nothing -> do - dir <- (`combine` "Downloads") <$> getHomeDirectory - ifM (doesDirectoryExist dir) (pure dir) getTemporaryDirectory - >>= (`uniqueCombine` fileName) - >>= createEmptyFile + -- perform an action only if filesFolder is set (i.e. on mobile devices) + withFilesFolder :: (FilePath -> m ()) -> m () + withFilesFolder action = asks filesFolder >>= readTVarIO >>= mapM_ action + deleteFiles :: FilePath -> [Maybe FilePath] -> m () + deleteFiles filesFolder filePaths = + forM_ filePaths $ \filePath_ -> + forM_ filePath_ $ \filePath -> do + let fsFilePath = filesFolder <> "/" <> filePath + removeFile fsFilePath `E.catch` \(_ :: E.SomeException) -> + removePathForcibly fsFilePath `E.catch` \(_ :: E.SomeException) -> pure () + cancelFiles :: UserId -> [(Int64, ACIFileStatus)] -> m () + cancelFiles userId files = + forM_ files $ \(fileId, status) -> do + case status of + AFS _ CIFSSndStored -> cancelById fileId + AFS _ CIFSRcvInvitation -> cancelById fileId + AFS _ CIFSRcvTransfer -> cancelById fileId + _ -> pure () + where + cancelById fileId = do + ft <- withStore (\st -> getFileTransfer st userId fileId) + void $ cancelFile userId fileId ft + cancelFile :: UserId -> Int64 -> FileTransfer -> m ChatResponse + cancelFile userId fileId ft = + case ft of + FTSnd ftm fts -> do + cancelFileTransfer CIFSSndCancelled + forM_ fts $ \ft' -> cancelSndFileTransfer ft' + pure $ CRSndGroupFileCancelled ftm fts + FTRcv ftr -> do + cancelFileTransfer CIFSRcvCancelled + cancelRcvFileTransfer ftr + pure $ CRRcvFileCancelled ftr + where + cancelFileTransfer :: MsgDirectionI d => CIFileStatus d -> m () + cancelFileTransfer ciFileStatus = + unless (fileTransferCancelled ft) $ + withStore $ \st -> do + updateFileCancelled st userId fileId + updateCIFileStatus st userId fileId ciFileStatus + +-- mobile clients use file paths relative to app directory (e.g. for the reason ios app directory changes on updates), +-- so we have to differentiate between the file path stored in db and communicated with frontend, and the file path +-- used during file transfer for actual operations with file system +toFSFilePath :: ChatMonad m => FilePath -> m FilePath +toFSFilePath f = + maybe f (<> "/" <> f) <$> (readTVarIO =<< asks filesFolder) + +acceptFileReceive :: forall m. ChatMonad m => User -> RcvFileTransfer -> Maybe FilePath -> m FilePath +acceptFileReceive user@User {userId} RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName = fName, fileConnReq}, fileStatus, senderDisplayName, grpMemberId} filePath_ = do + unless (fileStatus == RFSNew) . throwChatError $ CEFileAlreadyReceiving fName + case fileConnReq of + -- old file protocol + Just connReq -> + tryError (withAgent $ \a -> joinConnection a connReq . directMessage $ XFileAcpt fName) >>= \case + Right agentConnId -> do + filePath <- getRcvFilePath filePath_ fName + withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + pure filePath + Left e -> throwError e + -- new file protocol + Nothing -> + case grpMemberId of + Nothing -> do + ct <- withStore $ \st -> getContactByName st userId senderDisplayName + acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectContactMessage ct $ XFileAcptInv sharedMsgId fileInvConnReq fName + Just memId -> do + (GroupInfo {groupId}, GroupMember {activeConn}) <- withStore $ \st -> getGroupAndMember st user memId + case activeConn of + Just conn -> + acceptFileV2 $ \sharedMsgId fileInvConnReq -> sendDirectMessage conn (XFileAcptInv sharedMsgId fileInvConnReq fName) (GroupId groupId) + _ -> throwChatError $ CEFileInternal "member connection not active" -- should not happen + where + acceptFileV2 :: (SharedMsgId -> ConnReqInvitation -> m SndMessage) -> m FilePath + acceptFileV2 sendXFileAcptInv = do + sharedMsgId <- withStore $ \st -> getSharedMsgIdByFileId st userId fileId + (agentConnId, fileInvConnReq) <- withAgent (`createConnection` SCMInvitation) + filePath <- getRcvFilePath filePath_ fName + withStore $ \st -> acceptRcvFileTransfer st userId fileId agentConnId filePath + void $ sendXFileAcptInv sharedMsgId fileInvConnReq + pure filePath + where + getRcvFilePath :: Maybe FilePath -> String -> m FilePath + getRcvFilePath fPath_ fn = case fPath_ of + Nothing -> + asks filesFolder >>= readTVarIO >>= \case + Nothing -> do + dir <- (`combine` "Downloads") <$> getHomeDirectory + ifM (doesDirectoryExist dir) (pure dir) getTemporaryDirectory + >>= (`uniqueCombine` fn) + >>= createEmptyFile + Just filesFolder -> + filesFolder `uniqueCombine` fn + >>= createEmptyFile + >>= pure <$> takeFileName Just fPath -> ifM (doesDirectoryExist fPath) - (fPath `uniqueCombine` fileName >>= createEmptyFile) + (fPath `uniqueCombine` fn >>= createEmptyFile) $ ifM (doesFileExist fPath) (throwChatError $ CEFileAlreadyExists fPath) @@ -607,14 +776,14 @@ processChatCommand = \case h <- getFileHandle fileId fPath rcvFiles AppendMode liftIO $ B.hPut h "" >> hFlush h pure fPath - uniqueCombine :: FilePath -> String -> m FilePath - uniqueCombine filePath fileName = tryCombine (0 :: Int) - where - tryCombine n = - let (name, ext) = splitExtensions fileName - suffix = if n == 0 then "" else "_" <> show n - f = filePath `combine` (name <> suffix <> ext) - in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) + uniqueCombine :: FilePath -> String -> m FilePath + uniqueCombine filePath fileName = tryCombine (0 :: Int) + where + tryCombine n = + let (name, ext) = splitExtensions fileName + suffix = if n == 0 then "" else "_" <> show n + f = filePath `combine` (name <> suffix <> ext) + in ifM (doesFileExist f) (tryCombine $ n + 1) (pure f) acceptContactRequest :: ChatMonad m => User -> UserContactRequest -> m Contact acceptContactRequest User {userId, profile} UserContactRequest {agentInvitationId = AgentInvId invId, localDisplayName = cName, profileId, profile = p, xContactId} = do @@ -770,7 +939,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgNew mc -> newContentMessage ct mc msg msgMeta XMsgUpdate sharedMsgId mContent -> messageUpdate ct sharedMsgId mContent msg msgMeta XMsgDel sharedMsgId -> messageDelete ct sharedMsgId msg msgMeta - XFile fInv -> processFileInvitation ct fInv msg msgMeta + -- TODO discontinue XFile + XFile fInv -> processFileInvitation' ct fInv msg msgMeta + XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInv ct sharedMsgId fileConnReq fName msgMeta XInfo p -> xInfo ct p XGrpInv gInv -> processGroupInvitation ct gInv XInfoProbe probe -> xInfoProbe ct probe @@ -911,7 +1082,9 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta XMsgUpdate sharedMsgId mContent -> groupMessageUpdate gInfo m sharedMsgId mContent msg XMsgDel sharedMsgId -> groupMessageDelete gInfo m sharedMsgId msg - XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta + -- TODO discontinue XFile + XFile fInv -> processGroupFileInvitation' gInfo m fInv msg msgMeta + XFileAcptInv sharedMsgId fileConnReq fName -> xFileAcptInvGroup gInfo m sharedMsgId fileConnReq fName msgMeta XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo XGrpMemInv memId introInv -> xGrpMemInv gInfo m memId introInv @@ -932,6 +1105,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage processSndFileConn :: ACommand 'Agent -> Connection -> SndFileTransfer -> m () processSndFileConn agentMsg conn ft@SndFileTransfer {fileId, fileName, fileStatus} = case agentMsg of + -- old file protocol CONF confId connInfo -> do ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo case chatMsgEvent of @@ -962,8 +1136,14 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage _ -> pure () processRcvFileConn :: ACommand 'Agent -> Connection -> RcvFileTransfer -> m () - processRcvFileConn agentMsg _conn ft@RcvFileTransfer {fileId, chunkSize} = + processRcvFileConn agentMsg conn ft@RcvFileTransfer {fileId, chunkSize} = case agentMsg of + -- new file protocol + CONF confId connInfo -> do + ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage connInfo + case chatMsgEvent of + XOk -> allowAgentConnection conn confId XOk + _ -> pure () CON -> do withStore $ \st -> updateRcvFileStatus st ft FSConnected toView $ CRRcvFileStart ft @@ -988,10 +1168,12 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage then badRcvFileChunk ft "incorrect chunk size" else do appendFileChunk ft chunkNo chunk - withStore $ \st -> do + ci <- withStore $ \st -> do updateRcvFileStatus st ft FSComplete + updateCIFileStatus st userId fileId CIFSRcvComplete deleteRcvFileChunks st ft - toView $ CRRcvFileComplete ft + getChatItemByFileId st user fileId + toView $ CRRcvFileComplete ci closeFileHandle fileId rcvFiles withAgent (`deleteConnection` agentConnId) RcvChunkDuplicate -> pure () @@ -1082,13 +1264,24 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m () newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do - let content = mcContent mc - ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) + let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc + ciFile_ <- processFileInvitation fileInvitation_ $ + \fi chSize -> withStore $ \st -> createRcvFileTransfer st userId ct fi chSize + ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content) ciFile_ toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError showMsgToast (c <> "> ") content formattedText setActive $ ActiveC c + processFileInvitation :: Maybe FileInvitation -> (FileInvitation -> Integer -> m RcvFileTransfer) -> m (Maybe (CIFile 'MDRcv)) + processFileInvitation fileInvitation_ createRcvFileTransferF = case fileInvitation_ of + Nothing -> pure Nothing + Just fileInvitation@FileInvitation {fileName, fileSize} -> do + chSize <- asks $ fileChunkSize . config + RcvFileTransfer {fileId} <- createRcvFileTransferF fileInvitation chSize + let ciFile = CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} + pure $ Just ciFile + messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> m () messageUpdate ct@Contact {contactId} sharedMsgId mc RcvMessage {msgId} msgMeta = do CChatItem msgDir ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId @@ -1106,6 +1299,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage CChatItem msgDir deletedItem@ChatItem {meta = CIMeta {itemId}} <- withStore $ \st -> getDirectChatItemBySharedMsgId st userId contactId sharedMsgId case msgDir of SMDRcv -> do + -- TODO allow to locally delete items that were broadcast deleted by sender toCi <- withStore $ \st -> deleteDirectChatItemRcvBroadcast st userId ct itemId msgId toView $ CRChatItemDeleted (AChatItem SCTDirect SMDRcv (DirectChat ct) deletedItem) toCi checkIntegrity msgMeta $ toView . CRMsgIntegrityError @@ -1115,8 +1309,10 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do - let content = mcContent mc - ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) + let (ExtMsgContent content fileInvitation_) = mcExtMsgContent mc + ciFile_ <- processFileInvitation fileInvitation_ $ + \fi chSize -> withStore $ \st -> createRcvGroupFileTransfer st userId m fi chSize + ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content) ciFile_ groupMsgToView gInfo ci msgMeta let g = groupName' gInfo showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText @@ -1146,29 +1342,67 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage else messageError "x.msg.del: group member attempted to delete a message of another member" (SMDSnd, _) -> messageError "x.msg.del: group member attempted invalid message delete" - processFileInvitation :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processFileInvitation ct@Contact {localDisplayName = c} fInv msg msgMeta = do + -- TODO remove once XFile is discontinued + processFileInvitation' :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m () + processFileInvitation' ct@Contact {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do -- TODO chunk size has to be sent as part of invitation chSize <- asks $ fileChunkSize . config - ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize - ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvFileInvitation ft) - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize + let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} + ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci checkIntegrity msgMeta $ toView . CRMsgIntegrityError showToast (c <> "> ") "wants to send a file" setActive $ ActiveC c - processGroupFileInvitation :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () - processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msg msgMeta = do + -- TODO remove once XFile is discontinued + processGroupFileInvitation' :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m () + processGroupFileInvitation' gInfo m@GroupMember {localDisplayName = c} fInv@FileInvitation {fileName, fileSize} msg msgMeta = do chSize <- asks $ fileChunkSize . config - ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize - ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvFileInvitation ft) - withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci + RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize + let ciFile = Just $ CIFile {fileId, fileName, fileSize, filePath = Nothing, fileStatus = CIFSRcvInvitation} + ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent $ MCText "") ciFile groupMsgToView gInfo ci msgMeta let g = groupName' gInfo showToast ("#" <> g <> " " <> c <> "> ") "wants to send a file" setActive $ ActiveG g + xFileAcptInv :: Contact -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () + xFileAcptInv Contact {contactId} sharedMsgId fileConnReq fName msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + fileId <- withStore $ \st -> getFileIdBySharedMsgId st userId contactId sharedMsgId + withStore (\st -> getFileTransfer st userId fileId) >>= \case + FTSnd FileTransferMeta {fileName, cancelled} _ -> + if not cancelled + then + if fName == fileName + then + tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case + Right acId -> + withStore $ \st -> createSndFileTransferV2Connection st userId fileId acId + Left e -> throwError e + else messageError "x.file.acpt.inv: fileName is different from expected" + else pure () -- TODO send "file cancelled" message + _ -> messageError "x.file.acpt.inv: bad file direction" + + xFileAcptInvGroup :: GroupInfo -> GroupMember -> SharedMsgId -> ConnReqInvitation -> String -> MsgMeta -> m () + xFileAcptInvGroup GroupInfo {groupId} m sharedMsgId fileConnReq fName msgMeta = do + checkIntegrity msgMeta $ toView . CRMsgIntegrityError + fileId <- withStore $ \st -> getGroupFileIdBySharedMsgId st userId groupId sharedMsgId + withStore (\st -> getFileTransfer st userId fileId) >>= \case + FTSnd FileTransferMeta {fileName, cancelled} _ -> + if not cancelled + then + if fName == fileName + then + tryError (withAgent $ \a -> joinConnection a fileConnReq . directMessage $ XOk) >>= \case + Right acId -> + withStore $ \st -> createSndGroupFileTransferV2Connection st userId fileId acId m + Left e -> throwError e + else messageError "x.file.acpt.inv: fileName is different from expected" + else pure () -- TODO send "file cancelled" message + _ -> messageError "x.file.acpt.inv: bad file direction" + groupMsgToView :: GroupInfo -> ChatItem 'CTGroup 'MDRcv -> MsgMeta -> m () groupMsgToView gInfo ci msgMeta = do toView . CRNewChatItem $ AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci @@ -1343,11 +1577,12 @@ sendFileChunkNo ft@SndFileTransfer {agentConnId = AgentConnId acId} chunkNo = do withStore $ \st -> updateSndFileChunkMsg st ft chunkNo msgId readFileChunk :: ChatMonad m => SndFileTransfer -> Integer -> m ByteString -readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = - read_ `E.catch` (throwChatError . CEFileRead filePath . (show :: E.SomeException -> String)) +readFileChunk SndFileTransfer {fileId, filePath, chunkSize} chunkNo = do + fsFilePath <- toFSFilePath filePath + read_ fsFilePath `E.catch` (throwChatError . CEFileRead filePath . (show :: E.SomeException -> String)) where - read_ = do - h <- getFileHandle fileId filePath sndFiles ReadMode + read_ fsFilePath = do + h <- getFileHandle fileId fsFilePath sndFiles ReadMode pos <- hTell h let pos' = (chunkNo - 1) * chunkSize when (pos /= pos') $ hSeek h AbsoluteSeek pos' @@ -1375,12 +1610,14 @@ parseFileChunk msg = appendFileChunk :: ChatMonad m => RcvFileTransfer -> Integer -> ByteString -> m () appendFileChunk ft@RcvFileTransfer {fileId, fileStatus} chunkNo chunk = case fileStatus of - RFSConnected RcvFileInfo {filePath} -> append_ filePath + RFSConnected RcvFileInfo {filePath} -> do + fsFilePath <- toFSFilePath filePath + append_ filePath fsFilePath RFSCancelled _ -> pure () _ -> throwChatError $ CEFileInternal "receiving file transfer not in progress" where - append_ fPath = do - h <- getFileHandle fileId fPath rcvFiles AppendMode + append_ fPath fPathUsed = do + h <- getFileHandle fileId fPathUsed rcvFiles AppendMode E.try (liftIO $ B.hPut h chunk >> hFlush h) >>= \case Left (e :: E.SomeException) -> throwChatError . CEFileWrite fPath $ show e Right () -> withStore $ \st -> updatedRcvFileChunkStored st ft chunkNo @@ -1508,35 +1745,27 @@ saveRcvMSG Connection {connId} connOrGroupId agentMsgMeta msgBody = do rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta} withStore $ \st -> createNewMessageAndRcvMsgDelivery st connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery -sendDirectChatItem :: ChatMonad m => User -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTDirect) -> m (ChatItem 'CTDirect 'MDSnd) -sendDirectChatItem user ct chatMsgEvent ciContent quotedItem = do - msg <- sendDirectContactMessage ct chatMsgEvent - saveSndChatItem user (CDDirectSnd ct) msg ciContent quotedItem - -sendGroupChatItem :: ChatMonad m => User -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTGroup) -> m (ChatItem 'CTGroup 'MDSnd) -sendGroupChatItem user (Group g ms) chatMsgEvent ciContent quotedItem = do - msg <- sendGroupMessage g ms chatMsgEvent - saveSndChatItem user (CDGroupSnd g) msg ciContent quotedItem - -saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> m (ChatItem c 'MDSnd) -saveSndChatItem user cd msg@SndMessage {sharedMsgId} content quotedItem = do +saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIFile 'MDSnd) -> Maybe (CIQuote c) -> m (ChatItem c 'MDSnd) +saveSndChatItem user cd msg@SndMessage {sharedMsgId} content ciFile quotedItem = do createdAt <- liftIO getCurrentTime ciId <- withStore $ \st -> createNewSndChatItem st user cd msg content quotedItem createdAt - liftIO $ mkChatItem cd ciId content quotedItem (Just sharedMsgId) createdAt createdAt + forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId + liftIO $ mkChatItem cd ciId content ciFile quotedItem (Just sharedMsgId) createdAt createdAt -saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv) -saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content = do +saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> Maybe (CIFile 'MDRcv) -> m (ChatItem c 'MDRcv) +saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content ciFile = do createdAt <- liftIO getCurrentTime - (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt -- createNewChatItem st user cd $ mkNewChatItem content msg brokerTs createdAt - liftIO $ mkChatItem cd ciId content quotedItem sharedMsgId_ brokerTs createdAt + (ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt + forM_ ciFile $ \CIFile {fileId} -> withStore $ \st -> updateFileTransferChatItemId st fileId ciId + liftIO $ mkChatItem cd ciId content ciFile quotedItem sharedMsgId_ brokerTs createdAt -mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) -mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do +mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d) +mkChatItem cd ciId content file quotedItem sharedMsgId itemTs createdAt = do tz <- getCurrentTimeZone currentTs <- liftIO getCurrentTime let itemText = ciContentToText content meta = mkCIMeta ciId content itemText ciStatusNew sharedMsgId False False tz currentTs itemTs createdAt - pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem} + pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file} allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m () allowAgentConnection conn confId msg = do @@ -1650,10 +1879,11 @@ chatCommandP = ("/user " <|> "/u ") *> (CreateActiveUser <$> userProfile) <|> ("/user" <|> "/u") $> ShowActiveUser <|> "/_start" $> StartChat + <|> "/_files_folder " *> (SetFilesFolder <$> filePath) <|> "/_get chats" $> APIGetChats <|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP) <|> "/_get items count=" *> (APIGetChatItems <$> A.decimal) - <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP) + <|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <*> optional filePathTagged <*> optional quotedItemIdTagged <* A.space <*> msgContentP) <|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_update item " *> (APIUpdateChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP) <|> "/_delete item " *> (APIDeleteChatItem <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> ciDeleteMode) @@ -1662,6 +1892,7 @@ chatCommandP = <|> "/_accept " *> (APIAcceptContact <$> A.decimal) <|> "/_reject " *> (APIRejectContact <$> A.decimal) <|> "/_profile " *> (APIUpdateProfile <$> jsonP) + <|> "/_parse " *> (APIParseMarkdown . safeDecodeUtf8 <$> A.takeByteString) <|> "/smp_servers default" $> SetUserSMPServers [] <|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP) <|> "/smp_servers" $> GetUserSMPServers @@ -1682,7 +1913,7 @@ chatCommandP = <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString) <|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString) <|> ("\\#" <|> "\\ #") *> (DeleteGroupMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!#" <|> "! #") *> (EditGroupMessage <$> displayName <* A.space <*> quotedMsg <*> A.takeByteString) + <|> ("!#" <|> "! #") *> (EditGroupMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> ("/contacts" <|> "/cs") $> ListContacts <|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing)) <|> ("/connect" <|> "/c") $> AddContact @@ -1691,10 +1922,12 @@ chatCommandP = <|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv) <|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd) <|> ("\\@" <|> "\\ @") *> (DeleteMessage <$> displayName <* A.space <*> A.takeByteString) - <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> quotedMsg <*> A.takeByteString) + <|> ("!@" <|> "! @") *> (EditMessage <$> displayName <* A.space <*> (quotedMsg <|> pure "") <*> A.takeByteString) <|> "/feed " *> (SendMessageBroadcast <$> A.takeByteString) <|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath) + <|> ("/file_v2 #" <|> "/f_v2 #") *> (SendGroupFileInv <$> displayName <* A.space <*> filePath) <|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath) + <|> ("/file_v2 @" <|> "/file_v2 " <|> "/f_v2 @" <|> "/f_v2 ") *> (SendFileInv <$> displayName <* A.space <*> filePath) <|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath)) <|> ("/fcancel " <|> "/fc ") *> (CancelFile <$> A.decimal) <|> ("/fstatus " <|> "/fs ") *> (FileStatus <$> A.decimal) @@ -1707,7 +1940,7 @@ chatCommandP = <|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName) <|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown <|> ("/welcome" <|> "/w") $> Welcome - <|> "/profile_image " *> (UpdateProfileImage . Just . ProfileImage <$> imageP) + <|> "/profile_image " *> (UpdateProfileImage . Just . ImageData <$> imageP) <|> "/profile_image" $> UpdateProfileImage Nothing <|> ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames) <|> ("/profile" <|> "/p") $> ShowProfile @@ -1747,6 +1980,8 @@ chatCommandP = n <- (A.space *> A.takeByteString) <|> pure "" pure $ if B.null n then name else safeDecodeUtf8 n filePath = T.unpack . safeDecodeUtf8 <$> A.takeByteString + filePathTagged = " file " *> (T.unpack . safeDecodeUtf8 <$> A.takeTill (== ' ')) + quotedItemIdTagged = " quoted " *> A.decimal memberRole = (" owner" $> GROwner) <|> (" admin" $> GRAdmin) diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 93845ba2f0..83a8be168e 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -7,40 +7,17 @@ module Simplex.Chat.Bot where import Control.Concurrent.Async import Control.Concurrent.STM -import Control.Logger.Simple import Control.Monad.Reader import qualified Data.ByteString.Char8 as B import qualified Data.Text as T -import Data.Text.Encoding (encodeUtf8) -import Simplex.Chat import Simplex.Chat.Controller +import Simplex.Chat.Core import Simplex.Chat.Messages -import Simplex.Chat.Options (ChatOpts (..)) import Simplex.Chat.Store import Simplex.Chat.Types (Contact (..), User (..)) import Simplex.Messaging.Encoding.String (strEncode) import System.Exit (exitFailure) -simplexChatBot :: ChatConfig -> ChatOpts -> (User -> ChatController -> IO ()) -> IO () -simplexChatBot cfg@ChatConfig {dbPoolSize, yesToMigrations} opts chatBot - | logAgent opts = do - setLogLevel LogInfo -- LogError - withGlobalLogging logCfg initRun - | otherwise = initRun - where - initRun = do - let f = chatStoreFile $ dbFilePrefix opts - st <- createStore f dbPoolSize yesToMigrations - u <- getCreateActiveUser st - cc <- newChatController st (Just u) cfg opts (const $ pure ()) - runSimplexChatBot u cc chatBot - -runSimplexChatBot :: User -> ChatController -> (User -> ChatController -> IO ()) -> IO () -runSimplexChatBot u cc chatBot = do - a1 <- async $ chatBot u cc - a2 <- runReaderT (startChatController u) cc - waitEither_ a1 a2 - chatBotRepl :: String -> (String -> String) -> User -> ChatController -> IO () chatBotRepl welcome answer _user cc = do initializeBotAddress cc @@ -55,23 +32,20 @@ chatBotRepl welcome answer _user cc = do void . sendMsg contact $ answer msg _ -> pure () where - sendMsg Contact {contactId} msg = sendCmd cc $ "/_send @" <> show contactId <> " text " <> msg + sendMsg Contact {contactId} msg = sendChatCmd cc $ "/_send @" <> show contactId <> " text " <> msg contactConnected Contact {localDisplayName} = putStrLn $ T.unpack localDisplayName <> " connected" initializeBotAddress :: ChatController -> IO () initializeBotAddress cc = do - sendCmd cc "/show_address" >>= \case + sendChatCmd cc "/show_address" >>= \case CRUserContactLink uri _ -> showBotAddress uri CRChatCmdError (ChatErrorStore SEUserContactLinkNotFound) -> do putStrLn $ "No bot address, creating..." - sendCmd cc "/address" >>= \case + sendChatCmd cc "/address" >>= \case CRUserContactLinkCreated uri -> showBotAddress uri _ -> putStrLn "can't create bot address" >> exitFailure _ -> putStrLn "unexpected response" >> exitFailure where showBotAddress uri = do putStrLn $ "Bot's contact address is: " <> B.unpack (strEncode uri) - void $ sendCmd cc "/auto_accept on" - -sendCmd :: ChatController -> String -> IO ChatResponse -sendCmd cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc + void $ sendChatCmd cc "/auto_accept on" diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 132cc5ef5e..dcd334c610 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -25,6 +25,7 @@ import Data.Version (showVersion) import GHC.Generics (Generic) import Numeric.Natural import qualified Paths_simplex_chat as SC +import Simplex.Chat.Markdown (MarkdownList) import Simplex.Chat.Messages import Simplex.Chat.Protocol import Simplex.Chat.Store (StoreError) @@ -76,7 +77,8 @@ data ChatController = ChatController chatLock :: TMVar (), sndFiles :: TVar (Map Int64 Handle), rcvFiles :: TVar (Map Int64 Handle), - config :: ChatConfig + config :: ChatConfig, + filesFolder :: TVar (Maybe FilePath) -- path to files folder for mobile apps } data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSMessages @@ -90,11 +92,12 @@ data ChatCommand = ShowActiveUser | CreateActiveUser Profile | StartChat + | SetFilesFolder FilePath | APIGetChats | APIGetChat ChatType Int64 ChatPagination | APIGetChatItems Int - | APISendMessage ChatType Int64 MsgContent - | APISendMessageQuote ChatType Int64 ChatItemId MsgContent + | APISendMessage ChatType Int64 (Maybe FilePath) (Maybe ChatItemId) MsgContent + | APISendMessageQuote ChatType Int64 ChatItemId MsgContent -- TODO discontinue | APIUpdateChatItem ChatType Int64 ChatItemId MsgContent | APIDeleteChatItem ChatType Int64 ChatItemId CIDeleteMode | APIChatRead ChatType Int64 (ChatItemId, ChatItemId) @@ -102,6 +105,7 @@ data ChatCommand | APIAcceptContact Int64 | APIRejectContact Int64 | APIUpdateProfile Profile + | APIParseMarkdown Text | GetUserSMPServers | SetUserSMPServers [SMPServer] | ChatHelp HelpSection @@ -136,13 +140,15 @@ data ChatCommand | DeleteGroupMessage GroupName ByteString | EditGroupMessage {groupName :: ContactName, editedMsg :: ByteString, message :: ByteString} | SendFile ContactName FilePath + | SendFileInv ContactName FilePath | SendGroupFile GroupName FilePath + | SendGroupFileInv GroupName FilePath | ReceiveFile FileTransferId (Maybe FilePath) | CancelFile FileTransferId | FileStatus FileTransferId | ShowProfile | UpdateProfile ContactName Text - | UpdateProfileImage (Maybe ProfileImage) + | UpdateProfileImage (Maybe ImageData) | QuitChat | ShowVersion deriving (Show) @@ -153,6 +159,7 @@ data ChatResponse | CRChatRunning | CRApiChats {chats :: [AChat]} | CRApiChat {chat :: AChat} + | CRApiParsedMarkdown {formattedText :: Maybe MarkdownList} | CRUserSMPServers {smpServers :: [SMPServer]} | CRNewChatItem {chatItem :: AChatItem} | CRChatItemStatusUpdated {chatItem :: AChatItem} @@ -195,14 +202,14 @@ data ChatResponse | CRRcvFileAccepted {fileTransfer :: RcvFileTransfer, filePath :: FilePath} | CRRcvFileAcceptedSndCancelled {rcvFileTransfer :: RcvFileTransfer} | CRRcvFileStart {rcvFileTransfer :: RcvFileTransfer} - | CRRcvFileComplete {rcvFileTransfer :: RcvFileTransfer} + | CRRcvFileComplete {chatItem :: AChatItem} | CRRcvFileCancelled {rcvFileTransfer :: RcvFileTransfer} | CRRcvFileSndCancelled {rcvFileTransfer :: RcvFileTransfer} | CRSndFileStart {sndFileTransfer :: SndFileTransfer} | CRSndFileComplete {sndFileTransfer :: SndFileTransfer} | CRSndFileCancelled {sndFileTransfer :: SndFileTransfer} | CRSndFileRcvCancelled {sndFileTransfer :: SndFileTransfer} - | CRSndGroupFileCancelled {sndFileTransfers :: [SndFileTransfer]} + | CRSndGroupFileCancelled {fileTransferMeta :: FileTransferMeta, sndFileTransfers :: [SndFileTransfer]} | CRUserProfileUpdated {fromProfile :: Profile, toProfile :: Profile} | CRContactConnecting {contact :: Contact} | CRContactConnected {contact :: Contact} diff --git a/src/Simplex/Chat/Core.hs b/src/Simplex/Chat/Core.hs new file mode 100644 index 0000000000..a4faaf876f --- /dev/null +++ b/src/Simplex/Chat/Core.hs @@ -0,0 +1,38 @@ +{-# LANGUAGE DuplicateRecordFields #-} +{-# LANGUAGE NamedFieldPuns #-} + +module Simplex.Chat.Core where + +import Control.Logger.Simple +import Control.Monad.Reader +import qualified Data.Text as T +import Data.Text.Encoding (encodeUtf8) +import Simplex.Chat +import Simplex.Chat.Controller +import Simplex.Chat.Options (ChatOpts (..)) +import Simplex.Chat.Store +import Simplex.Chat.Types +import UnliftIO.Async + +simplexChatCore :: ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> (User -> ChatController -> IO ()) -> IO () +simplexChatCore cfg@ChatConfig {dbPoolSize, yesToMigrations} opts sendToast chat + | logAgent opts = do + setLogLevel LogInfo -- LogError + withGlobalLogging logCfg initRun + | otherwise = initRun + where + initRun = do + let f = chatStoreFile $ dbFilePrefix opts + st <- createStore f dbPoolSize yesToMigrations + u <- getCreateActiveUser st + cc <- newChatController st (Just u) cfg opts sendToast + runSimplexChat u cc chat + +runSimplexChat :: User -> ChatController -> (User -> ChatController -> IO ()) -> IO () +runSimplexChat u cc chat = do + a1 <- async $ chat u cc + a2 <- runReaderT (startChatController u) cc + waitEither_ a1 a2 + +sendChatCmd :: ChatController -> String -> IO ChatResponse +sendChatCmd cc s = runReaderT (execChatCommand . encodeUtf8 $ T.pack s) cc diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index b3d1784eb3..7725a7341e 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -160,7 +160,8 @@ messagesHelpInfo = indent <> highlight "\\ #team hi " <> " - to delete your message in the group #team", "", green "Editing sent messages", - "To edit a message that starts with \"hi\":", + "To edit your last message press up arrow, edit (keep the initial ! symbol) and press enter.", + "To edit your most recent message that starts with \"hi\":", indent <> highlight "! @alice (hi) " <> " - to edit your message to alice", indent <> highlight "! #team (hi) " <> " - to edit your message in the group #team" ] diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 1a430ed831..b2bc8d2218 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -20,7 +20,6 @@ import qualified Data.ByteString.Base64 as B64 import qualified Data.ByteString.Lazy.Char8 as LB import Data.Int (Int64) import Data.Text (Text) -import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1, encodeUtf8) import Data.Time.Clock (UTCTime, diffUTCTime, nominalDay) import Data.Time.LocalTime (TimeZone, ZonedTime, utcToZonedTime) @@ -79,11 +78,12 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem meta :: CIMeta d, content :: CIContent d, formattedText :: Maybe MarkdownList, - quotedItem :: Maybe (CIQuote c) + quotedItem :: Maybe (CIQuote c), + file :: Maybe (CIFile d) } deriving (Show, Generic) -instance ToJSON (ChatItem c d) where +instance MsgDirectionI d => ToJSON (ChatItem c d) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} @@ -196,7 +196,7 @@ instance ToJSON AChatItem where data JSONAnyChatItem c d = JSONAnyChatItem {chatInfo :: ChatInfo c, chatItem :: ChatItem c d} deriving (Generic) -instance ToJSON (JSONAnyChatItem c d) where +instance MsgDirectionI d => ToJSON (JSONAnyChatItem c d) where toJSON = J.genericToJSON J.defaultOptions toEncoding = J.genericToEncoding J.defaultOptions @@ -265,6 +265,63 @@ quoteMsgDirection = \case CIQGroupSnd -> MDSnd CIQGroupRcv _ -> MDRcv +data CIFile (d :: MsgDirection) = CIFile + { fileId :: Int64, + fileName :: String, + fileSize :: Integer, + filePath :: Maybe FilePath, -- local file path + fileStatus :: CIFileStatus d + } + deriving (Show, Generic) + +instance MsgDirectionI d => ToJSON (CIFile d) where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data CIFileStatus (d :: MsgDirection) where + CIFSSndStored :: CIFileStatus 'MDSnd + CIFSSndCancelled :: CIFileStatus 'MDSnd + CIFSRcvInvitation :: CIFileStatus 'MDRcv + CIFSRcvTransfer :: CIFileStatus 'MDRcv + CIFSRcvComplete :: CIFileStatus 'MDRcv + CIFSRcvCancelled :: CIFileStatus 'MDRcv + +deriving instance Show (CIFileStatus d) + +instance MsgDirectionI d => ToJSON (CIFileStatus d) where + toJSON = strToJSON + toEncoding = strToJEncoding + +instance MsgDirectionI d => ToField (CIFileStatus d) where toField = toField . decodeLatin1 . strEncode + +instance FromField ACIFileStatus where fromField = fromTextField_ $ eitherToMaybe . strDecode . encodeUtf8 + +data ACIFileStatus = forall d. MsgDirectionI d => AFS (SMsgDirection d) (CIFileStatus d) + +deriving instance Show ACIFileStatus + +instance MsgDirectionI d => StrEncoding (CIFileStatus d) where + strEncode = \case + CIFSSndStored -> "snd_stored" + CIFSSndCancelled -> "snd_cancelled" + CIFSRcvInvitation -> "rcv_invitation" + CIFSRcvTransfer -> "rcv_transfer" + CIFSRcvComplete -> "rcv_complete" + CIFSRcvCancelled -> "rcv_cancelled" + strP = (\(AFS _ st) -> checkDirection st) <$?> strP + +instance StrEncoding ACIFileStatus where + strEncode (AFS _ s) = strEncode s + strP = + A.takeTill (== ' ') >>= \case + "snd_stored" -> pure $ AFS SMDSnd CIFSSndStored + "snd_cancelled" -> pure $ AFS SMDSnd CIFSSndCancelled + "rcv_invitation" -> pure $ AFS SMDRcv CIFSRcvInvitation + "rcv_transfer" -> pure $ AFS SMDRcv CIFSRcvTransfer + "rcv_complete" -> pure $ AFS SMDRcv CIFSRcvComplete + "rcv_cancelled" -> pure $ AFS SMDRcv CIFSRcvCancelled + _ -> fail "bad file status" + data CIStatus (d :: MsgDirection) where CISSndNew :: CIStatus 'MDSnd CISSndSent :: CIStatus 'MDSnd @@ -366,8 +423,6 @@ data CIContent (d :: MsgDirection) where CIRcvMsgContent :: MsgContent -> CIContent 'MDRcv CISndDeleted :: CIDeleteMode -> CIContent 'MDSnd CIRcvDeleted :: CIDeleteMode -> CIContent 'MDRcv - CISndFileInvitation :: FileTransferId -> FilePath -> CIContent 'MDSnd - CIRcvFileInvitation :: RcvFileTransfer -> CIContent 'MDRcv deriving instance Show (CIContent d) @@ -377,8 +432,6 @@ ciContentToText = \case CIRcvMsgContent mc -> msgContentText mc CISndDeleted cidm -> ciDeleteModeToText cidm CIRcvDeleted cidm -> ciDeleteModeToText cidm - CISndFileInvitation fId fPath -> "you sent file #" <> T.pack (show fId) <> ": " <> T.pack fPath - CIRcvFileInvitation RcvFileTransfer {fileInvitation = FileInvitation {fileName}} -> "file " <> T.pack fileName msgDirToDeletedContent_ :: SMsgDirection d -> CIDeleteMode -> CIContent d msgDirToDeletedContent_ msgDir mode = case msgDir of @@ -411,8 +464,6 @@ data JSONCIContent | JCIRcvMsgContent {msgContent :: MsgContent} | JCISndDeleted {deleteMode :: CIDeleteMode} | JCIRcvDeleted {deleteMode :: CIDeleteMode} - | JCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} - | JCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} deriving (Generic) instance FromJSON JSONCIContent where @@ -428,8 +479,6 @@ jsonCIContent = \case CIRcvMsgContent mc -> JCIRcvMsgContent mc CISndDeleted cidm -> JCISndDeleted cidm CIRcvDeleted cidm -> JCIRcvDeleted cidm - CISndFileInvitation fId fPath -> JCISndFileInvitation fId fPath - CIRcvFileInvitation ft -> JCIRcvFileInvitation ft aciContentJSON :: JSONCIContent -> ACIContent aciContentJSON = \case @@ -437,8 +486,6 @@ aciContentJSON = \case JCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc JCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm JCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm - JCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath - JCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft -- platform independent data DBJSONCIContent @@ -446,8 +493,6 @@ data DBJSONCIContent | DBJCIRcvMsgContent {msgContent :: MsgContent} | DBJCISndDeleted {deleteMode :: CIDeleteMode} | DBJCIRcvDeleted {deleteMode :: CIDeleteMode} - | DBJCISndFileInvitation {fileId :: FileTransferId, filePath :: FilePath} - | DBJCIRcvFileInvitation {rcvFileTransfer :: RcvFileTransfer} deriving (Generic) instance FromJSON DBJSONCIContent where @@ -463,8 +508,6 @@ dbJsonCIContent = \case CIRcvMsgContent mc -> DBJCIRcvMsgContent mc CISndDeleted cidm -> DBJCISndDeleted cidm CIRcvDeleted cidm -> DBJCIRcvDeleted cidm - CISndFileInvitation fId fPath -> DBJCISndFileInvitation fId fPath - CIRcvFileInvitation ft -> DBJCIRcvFileInvitation ft aciContentDBJSON :: DBJSONCIContent -> ACIContent aciContentDBJSON = \case @@ -472,8 +515,6 @@ aciContentDBJSON = \case DBJCIRcvMsgContent mc -> ACIContent SMDRcv $ CIRcvMsgContent mc DBJCISndDeleted cidm -> ACIContent SMDSnd $ CISndDeleted cidm DBJCIRcvDeleted cidm -> ACIContent SMDRcv $ CIRcvDeleted cidm - DBJCISndFileInvitation fId fPath -> ACIContent SMDSnd $ CISndFileInvitation fId fPath - DBJCIRcvFileInvitation ft -> ACIContent SMDRcv $ CIRcvFileInvitation ft data SChatType (c :: ChatType) where SCTDirect :: SChatType 'CTDirect diff --git a/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs b/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs new file mode 100644 index 0000000000..40623a3be6 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20220404_files_status_fields.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20220404_files_status_fields where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20220404_files_status_fields :: Query +m20220404_files_status_fields = + [sql| +ALTER TABLE files ADD COLUMN cancelled INTEGER; -- 1 for cancelled +ALTER TABLE files ADD COLUMN ci_file_status TEXT; -- CIFileStatus + +DELETE FROM chat_items +WHERE chat_item_id IN ( + SELECT chat_item_id + FROM files +); +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql new file mode 100644 index 0000000000..6e1b0ff732 --- /dev/null +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -0,0 +1,283 @@ +CREATE TABLE migrations ( + name TEXT NOT NULL, + ts TEXT NOT NULL, + PRIMARY KEY (name) + ); +CREATE TABLE contact_profiles ( -- remote user profile + contact_profile_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, -- contact name set by remote user (not unique), this name must not contain spaces + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}' -- JSON with contact profile properties +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), image TEXT); +CREATE INDEX contact_profiles_index ON contact_profiles (display_name, full_name); +CREATE TABLE users ( + user_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + local_display_name TEXT NOT NULL UNIQUE, + active_user INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 1 for active user + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE + DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE display_names ( + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + ldn_base TEXT NOT NULL, + ldn_suffix INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + PRIMARY KEY (user_id, local_display_name) ON CONFLICT FAIL, + UNIQUE (user_id, ldn_base, ldn_suffix) ON CONFLICT FAIL +) WITHOUT ROWID; +CREATE TABLE contacts ( + contact_id INTEGER PRIMARY KEY, + contact_profile_id INTEGER REFERENCES contact_profiles ON DELETE SET NULL, -- NULL if it's an incognito profile + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, + is_user INTEGER NOT NULL DEFAULT 0, -- 1 if this contact is a user + via_group INTEGER REFERENCES groups (group_id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT CHECK (updated_at NOT NULL), xcontact_id BLOB, + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, contact_profile_id) +); +CREATE TABLE sent_probes ( + sent_probe_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL UNIQUE REFERENCES contacts ON DELETE CASCADE, + probe BLOB NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (user_id, probe) +); +CREATE TABLE sent_probe_hashes ( + sent_probe_hash_id INTEGER PRIMARY KEY, + sent_probe_id INTEGER NOT NULL REFERENCES sent_probes ON DELETE CASCADE, + contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (sent_probe_id, contact_id) +); +CREATE TABLE received_probes ( + received_probe_id INTEGER PRIMARY KEY, + contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE, + probe BLOB, + probe_hash BLOB NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL)); +CREATE TABLE known_servers( + server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (user_id, host, port) +) WITHOUT ROWID; +CREATE TABLE group_profiles ( -- shared group profiles + group_profile_id INTEGER PRIMARY KEY, + display_name TEXT NOT NULL, -- this name must not contain spaces + full_name TEXT NOT NULL, + properties TEXT NOT NULL DEFAULT '{}' -- JSON with user or contact profile +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), image TEXT); +CREATE TABLE groups ( + group_id INTEGER PRIMARY KEY, -- local group ID + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, -- local group name without spaces + group_profile_id INTEGER REFERENCES group_profiles ON DELETE SET NULL, -- shared group profile + inv_queue_info BLOB, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- received + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, group_profile_id) +); +CREATE INDEX idx_groups_inv_queue_info ON groups (inv_queue_info); +CREATE TABLE group_members ( -- group members, excluding the local user + group_member_id INTEGER PRIMARY KEY, + group_id INTEGER NOT NULL REFERENCES groups ON DELETE CASCADE, + member_id BLOB NOT NULL, -- shared member ID, unique per group + member_role TEXT NOT NULL, -- owner, admin, member + member_category TEXT NOT NULL, -- see GroupMemberCategory + member_status TEXT NOT NULL, -- see GroupMemberStatus + invited_by INTEGER REFERENCES contacts (contact_id) ON DELETE SET NULL, -- NULL for the members who joined before the current user and for the group creator + sent_inv_queue_info BLOB, -- sent + group_queue_info BLOB, -- received + direct_queue_info BLOB, -- received + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + local_display_name TEXT NOT NULL, -- should be the same as contact + contact_profile_id INTEGER NOT NULL REFERENCES contact_profiles ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON DELETE CASCADE + ON UPDATE CASCADE, + UNIQUE (group_id, member_id) +); +CREATE TABLE group_member_intros ( + group_member_intro_id INTEGER PRIMARY KEY, + re_group_member_id INTEGER NOT NULL REFERENCES group_members (group_member_id) ON DELETE CASCADE, + to_group_member_id INTEGER NOT NULL REFERENCES group_members (group_member_id) ON DELETE CASCADE, + group_queue_info BLOB, + direct_queue_info BLOB, + intro_status TEXT NOT NULL, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- see GroupMemberIntroStatus + UNIQUE (re_group_member_id, to_group_member_id) +); +CREATE TABLE files ( + file_id INTEGER PRIMARY KEY, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + file_name TEXT NOT NULL, + file_path TEXT, + file_size INTEGER NOT NULL, + chunk_size INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE +, chat_item_id INTEGER DEFAULT NULL REFERENCES chat_items ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), cancelled INTEGER, ci_file_status TEXT); +CREATE TABLE snd_files ( + file_id INTEGER NOT NULL REFERENCES files ON DELETE CASCADE, + connection_id INTEGER NOT NULL REFERENCES connections ON DELETE CASCADE, + file_status TEXT NOT NULL, -- new, accepted, connected, completed + group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), + PRIMARY KEY (file_id, connection_id) +) WITHOUT ROWID; +CREATE TABLE rcv_files ( + file_id INTEGER PRIMARY KEY REFERENCES files ON DELETE CASCADE, + file_status TEXT NOT NULL, -- new, accepted, connected, completed + group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, + file_queue_info BLOB +, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL)); +CREATE TABLE snd_file_chunks ( + file_id INTEGER NOT NULL, + connection_id INTEGER NOT NULL, + chunk_number INTEGER NOT NULL, + chunk_agent_msg_id INTEGER, + chunk_sent INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 0 (sent to agent), 1 (sent to server) + FOREIGN KEY (file_id, connection_id) REFERENCES snd_files ON DELETE CASCADE, + PRIMARY KEY (file_id, connection_id, chunk_number) +) WITHOUT ROWID; +CREATE TABLE rcv_file_chunks ( + file_id INTEGER NOT NULL REFERENCES rcv_files ON DELETE CASCADE, + chunk_number INTEGER NOT NULL, + chunk_agent_msg_id INTEGER NOT NULL, + chunk_stored INTEGER NOT NULL DEFAULT 0, created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- 0 (received), 1 (appended to file) + PRIMARY KEY (file_id, chunk_number) +) WITHOUT ROWID; +CREATE TABLE connections ( -- all SMP agent connections + connection_id INTEGER PRIMARY KEY, + agent_conn_id BLOB NOT NULL UNIQUE, + conn_level INTEGER NOT NULL DEFAULT 0, + via_contact INTEGER REFERENCES contacts (contact_id) ON DELETE SET NULL, + conn_status TEXT NOT NULL, + conn_type TEXT NOT NULL, -- contact, member, rcv_file, snd_file + user_contact_link_id INTEGER REFERENCES user_contact_links ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE, + snd_file_id INTEGER, + rcv_file_id INTEGER REFERENCES rcv_files (file_id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), via_contact_uri_hash BLOB, xcontact_id BLOB, + FOREIGN KEY (snd_file_id, connection_id) + REFERENCES snd_files (file_id, connection_id) + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED +); +CREATE TABLE user_contact_links ( + user_contact_link_id INTEGER PRIMARY KEY, + conn_req_contact BLOB NOT NULL, + local_display_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), auto_accept INTEGER DEFAULT 0, + UNIQUE (user_id, local_display_name) +); +CREATE TABLE contact_requests ( + contact_request_id INTEGER PRIMARY KEY, + user_contact_link_id INTEGER NOT NULL REFERENCES user_contact_links + ON UPDATE CASCADE ON DELETE CASCADE, + agent_invitation_id BLOB NOT NULL, + contact_profile_id INTEGER REFERENCES contact_profiles + ON DELETE SET NULL -- NULL if it's an incognito profile + DEFERRABLE INITIALLY DEFERRED, + local_display_name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, updated_at TEXT CHECK (updated_at NOT NULL), xcontact_id BLOB, + FOREIGN KEY (user_id, local_display_name) + REFERENCES display_names (user_id, local_display_name) + ON UPDATE CASCADE + ON DELETE CASCADE + DEFERRABLE INITIALLY DEFERRED, + UNIQUE (user_id, local_display_name), + UNIQUE (user_id, contact_profile_id) +); +CREATE TABLE messages ( + message_id INTEGER PRIMARY KEY, + msg_sent INTEGER NOT NULL, -- 0 for received, 1 for sent + chat_msg_event TEXT NOT NULL, -- message event tag (the constructor of CMEventTag) + msg_body BLOB, -- agent message body as received or sent + created_at TEXT NOT NULL DEFAULT (datetime('now')) +, updated_at TEXT CHECK (updated_at NOT NULL), connection_id INTEGER DEFAULT NULL REFERENCES connections ON DELETE CASCADE, group_id INTEGER DEFAULT NULL REFERENCES groups ON DELETE CASCADE, shared_msg_id BLOB, shared_msg_id_user INTEGER); +CREATE TABLE msg_deliveries ( + msg_delivery_id INTEGER PRIMARY KEY, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, -- non UNIQUE for group messages + connection_id INTEGER NOT NULL REFERENCES connections ON DELETE CASCADE, + agent_msg_id INTEGER, -- internal agent message ID (NULL while pending) + agent_msg_meta TEXT, -- JSON with timestamps etc. sent in MSG, NULL for sent + chat_ts TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT CHECK (created_at NOT NULL), updated_at TEXT CHECK (updated_at NOT NULL), -- broker_ts for received, created_at for sent + UNIQUE (connection_id, agent_msg_id) +); +CREATE TABLE msg_delivery_events ( + msg_delivery_event_id INTEGER PRIMARY KEY, + msg_delivery_id INTEGER NOT NULL REFERENCES msg_deliveries ON DELETE CASCADE, -- non UNIQUE for multiple events per msg delivery + delivery_status TEXT NOT NULL, -- see MsgDeliveryStatus for allowed values + created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT CHECK (updated_at NOT NULL), + UNIQUE (msg_delivery_id, delivery_status) +); +CREATE TABLE pending_group_messages ( + pending_group_message_id INTEGER PRIMARY KEY, + group_member_id INTEGER NOT NULL REFERENCES group_members ON DELETE CASCADE, + message_id INTEGER NOT NULL REFERENCES messages ON DELETE CASCADE, + group_member_intro_id INTEGER REFERENCES group_member_intros ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE TABLE chat_items ( + chat_item_id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- NULL for sent even if group_id is not + chat_msg_id INTEGER, -- sent as part of the message that created the item + created_by_msg_id INTEGER UNIQUE REFERENCES messages (message_id) ON DELETE SET NULL, + item_sent INTEGER NOT NULL, -- 0 for received, 1 for sent + item_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent + item_deleted INTEGER NOT NULL DEFAULT 0, -- 1 for deleted, + item_content TEXT NOT NULL, -- JSON + item_text TEXT NOT NULL, -- textual representation + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +, item_status TEXT CHECK (item_status NOT NULL), shared_msg_id BLOB, quoted_shared_msg_id BLOB, quoted_sent_at TEXT, quoted_content TEXT, quoted_sent INTEGER, quoted_member_id BLOB, item_edited INTEGER); +CREATE TABLE chat_item_messages ( + chat_item_id INTEGER NOT NULL REFERENCES chat_items ON DELETE CASCADE, + message_id INTEGER NOT NULL UNIQUE REFERENCES messages ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (chat_item_id, message_id) +); +CREATE INDEX idx_connections_via_contact_uri_hash ON connections (via_contact_uri_hash); +CREATE INDEX idx_contact_requests_xcontact_id ON contact_requests (xcontact_id); +CREATE INDEX idx_contacts_xcontact_id ON contacts (xcontact_id); +CREATE TABLE smp_servers ( + smp_server_id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port TEXT NOT NULL, + key_hash BLOB NOT NULL, + user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (host, port) +); +CREATE INDEX idx_messages_shared_msg_id ON messages (shared_msg_id); +CREATE UNIQUE INDEX idx_messages_direct_shared_msg_id ON messages (connection_id, shared_msg_id_user, shared_msg_id); +CREATE UNIQUE INDEX idx_messages_group_shared_msg_id ON messages (group_id, shared_msg_id_user, shared_msg_id); +CREATE INDEX idx_chat_items_shared_msg_id ON chat_items (shared_msg_id); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index e4f15dd0d1..b830860ef5 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -48,10 +48,12 @@ cChatRecvMsg cc = deRefStablePtr cc >>= chatRecvMsg >>= newCAString mobileChatOpts :: ChatOpts mobileChatOpts = ChatOpts - { dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db + { dbFilePrefix = undefined, smpServers = [], logConnections = False, - logAgent = False + logAgent = False, + chatCmd = "", + chatCmdDelay = 3 } defaultMobileConfig :: ChatConfig @@ -71,7 +73,7 @@ chatInit dbFilePrefix = do let f = chatStoreFile dbFilePrefix chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations (defaultMobileConfig :: ChatConfig)) user_ <- getActiveUser_ chatStore - newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} (const $ pure ()) + newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} Nothing chatSendCmd :: ChatController -> String -> IO JSONString chatSendCmd cc s = LB.unpack . J.encode . APIResponse Nothing <$> runReaderT (execChatCommand $ B.pack s) cc diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index 68b9394db1..09cc1e9f85 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE ApplicativeDo #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} module Simplex.Chat.Options @@ -20,13 +22,15 @@ data ChatOpts = ChatOpts { dbFilePrefix :: String, smpServers :: [SMPServer], logConnections :: Bool, - logAgent :: Bool + logAgent :: Bool, + chatCmd :: String, + chatCmdDelay :: Int } chatOpts :: FilePath -> FilePath -> Parser ChatOpts -chatOpts appDir defaultDbFileName = - ChatOpts - <$> strOption +chatOpts appDir defaultDbFileName = do + dbFilePrefix <- + strOption ( long "database" <> short 'd' <> metavar "DB_FILE" @@ -34,7 +38,8 @@ chatOpts appDir defaultDbFileName = <> value defaultDbFilePath <> showDefault ) - <*> option + smpServers <- + option parseSMPServers ( long "server" <> short 's' @@ -43,16 +48,37 @@ chatOpts appDir defaultDbFileName = "Comma separated list of SMP server(s) to use" <> value [] ) - <*> switch + logConnections <- + switch ( long "connections" <> short 'c' <> help "Log every contact and group connection on start" ) - <*> switch + logAgent <- + switch ( long "log-agent" <> short 'l' <> help "Enable logs from SMP agent" ) + chatCmd <- + strOption + ( long "execute" + <> short 'e' + <> metavar "COMMAND" + <> help "Execute chat command (received messages won't be logged) and exit" + <> value "" + ) + chatCmdDelay <- + option + auto + ( long "time" + <> short 't' + <> metavar "TIME" + <> help "Time to wait after sending chat command before exiting, seconds" + <> value 3 + <> showDefault + ) + pure ChatOpts {dbFilePrefix, smpServers, logConnections, logAgent, chatCmd, chatCmdDelay} where defaultDbFilePath = combine appDir defaultDbFileName diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 04df7a9a5c..b52786fe86 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -112,8 +112,9 @@ data ChatMsgEvent | XMsgUpdate SharedMsgId MsgContent | XMsgDel SharedMsgId | XMsgDeleted - | XFile FileInvitation - | XFileAcpt String + | XFile FileInvitation -- TODO discontinue + | XFileAcpt String -- old file protocol + | XFileAcptInv SharedMsgId ConnReqInvitation String -- new file protocol | XInfo Profile | XContact Profile (Maybe XContactId) | XGrpInv GroupInvitation @@ -147,14 +148,18 @@ cmToQuotedMsg = \case XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg _ -> Nothing -data MsgContentTag = MCText_ | MCUnknown_ Text +data MsgContentTag = MCText_ | MCLink_ | MCImage_ | MCUnknown_ Text instance StrEncoding MsgContentTag where strEncode = \case MCText_ -> "text" + MCLink_ -> "link" + MCImage_ -> "image" MCUnknown_ t -> encodeUtf8 t strDecode = \case "text" -> Right MCText_ + "link" -> Right MCLink_ + "image" -> Right MCImage_ t -> Right . MCUnknown_ $ safeDecodeUtf8 t strP = strDecode <$?> A.takeTill (== ' ') @@ -166,44 +171,71 @@ instance ToJSON MsgContentTag where toEncoding = strToJEncoding data MsgContainer - = MCSimple MsgContent - | MCQuote QuotedMsg MsgContent - | MCForward MsgContent + = MCSimple ExtMsgContent + | MCQuote QuotedMsg ExtMsgContent + | MCForward ExtMsgContent deriving (Eq, Show) -mcContent :: MsgContainer -> MsgContent -mcContent = \case +mcExtMsgContent :: MsgContainer -> ExtMsgContent +mcExtMsgContent = \case MCSimple c -> c MCQuote _ c -> c MCForward c -> c +data LinkPreview = LinkPreview {uri :: Text, title :: Text, description :: Text, image :: ImageData} + deriving (Eq, Show, Generic) + +instance FromJSON LinkPreview where + parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True} + +instance ToJSON LinkPreview where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + data MsgContent = MCText Text + | MCLink {text :: Text, preview :: LinkPreview} + | MCImage {text :: Text, image :: ImageData} | MCUnknown {tag :: Text, text :: Text, json :: J.Object} deriving (Eq, Show) msgContentText :: MsgContent -> Text msgContentText = \case MCText t -> t + MCLink {text} -> text + MCImage {text} -> text MCUnknown {text} -> text msgContentTag :: MsgContent -> MsgContentTag msgContentTag = \case MCText _ -> MCText_ + MCLink {} -> MCLink_ + MCImage {} -> MCImage_ MCUnknown {tag} -> MCUnknown_ tag +data ExtMsgContent = ExtMsgContent MsgContent (Maybe FileInvitation) + deriving (Eq, Show) + parseMsgContainer :: J.Object -> JT.Parser MsgContainer parseMsgContainer v = MCQuote <$> v .: "quote" <*> mc <|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc) <|> MCSimple <$> mc where - mc = v .: "content" + mc = ExtMsgContent <$> v .: "content" <*> v .:? "file" instance FromJSON MsgContent where parseJSON (J.Object v) = v .: "type" >>= \case MCText_ -> MCText <$> v .: "text" + MCLink_ -> do + text <- v .: "text" + preview <- v .: "preview" + pure MCLink {text, preview} + MCImage_ -> do + text <- v .: "text" + image <- v .: "image" + pure MCImage {image, text} MCUnknown_ tag -> do text <- fromMaybe unknownMsgType <$> v .:? "text" pure MCUnknown {tag, text, json = v} @@ -215,17 +247,25 @@ unknownMsgType = "unknown message type" msgContainerJSON :: MsgContainer -> J.Object msgContainerJSON = \case - MCQuote qm c -> JM.fromList ["quote" .= qm, "content" .= c] - MCForward c -> JM.fromList ["forward" .= True, "content" .= c] - MCSimple c -> JM.fromList ["content" .= c] + MCQuote qm (ExtMsgContent c file) -> JM.fromList $ withFile ["quote" .= qm, "content" .= c] file + MCForward (ExtMsgContent c file) -> JM.fromList $ withFile ["forward" .= True, "content" .= c] file + MCSimple (ExtMsgContent c file) -> JM.fromList $ withFile ["content" .= c] file + where + withFile l = \case + Nothing -> l + Just f -> l <> ["file" .= fileInvitationJSON f] instance ToJSON MsgContent where toJSON = \case MCUnknown {json} -> J.Object json MCText t -> J.object ["type" .= MCText_, "text" .= t] + MCLink {text, preview} -> J.object ["type" .= MCLink_, "text" .= text, "preview" .= preview] + MCImage {text, image} -> J.object ["type" .= MCImage_, "text" .= text, "image" .= image] toEncoding = \case MCUnknown {json} -> JE.value $ J.Object json MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t + MCLink {text, preview} -> J.pairs $ "type" .= MCLink_ <> "text" .= text <> "preview" .= preview + MCImage {text, image} -> J.pairs $ "type" .= MCImage_ <> "text" .= text <> "image" .= image instance ToField MsgContent where toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode @@ -240,6 +280,7 @@ data CMEventTag | XMsgDeleted_ | XFile_ | XFileAcpt_ + | XFileAcptInv_ | XInfo_ | XContact_ | XGrpInv_ @@ -269,6 +310,7 @@ instance StrEncoding CMEventTag where XMsgDeleted_ -> "x.msg.deleted" XFile_ -> "x.file" XFileAcpt_ -> "x.file.acpt" + XFileAcptInv_ -> "x.file.acpt.inv" XInfo_ -> "x.info" XContact_ -> "x.contact" XGrpInv_ -> "x.grp.inv" @@ -295,6 +337,7 @@ instance StrEncoding CMEventTag where "x.msg.deleted" -> Right XMsgDeleted_ "x.file" -> Right XFile_ "x.file.acpt" -> Right XFileAcpt_ + "x.file.acpt.inv" -> Right XFileAcptInv_ "x.info" -> Right XInfo_ "x.contact" -> Right XContact_ "x.grp.inv" -> Right XGrpInv_ @@ -324,6 +367,7 @@ toCMEventTag = \case XMsgDeleted -> XMsgDeleted_ XFile _ -> XFile_ XFileAcpt _ -> XFileAcpt_ + XFileAcptInv {} -> XFileAcptInv_ XInfo _ -> XInfo_ XContact _ _ -> XContact_ XGrpInv _ -> XGrpInv_ @@ -371,6 +415,7 @@ appToChatMessage AppMessage {msgId, event, params} = do XMsgDeleted_ -> pure XMsgDeleted XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" + XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> p "fileConnReq" <*> p "fileName" XInfo_ -> XInfo <$> p "profile" XContact_ -> XContact <$> p "profile" <*> opt "contactReqId" XGrpInv_ -> XGrpInv <$> p "groupInvitation" @@ -403,8 +448,9 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XMsgUpdate msgId' content -> o ["msgId" .= msgId', "content" .= content] XMsgDel msgId' -> o ["msgId" .= msgId'] XMsgDeleted -> JM.empty - XFile fileInv -> o ["file" .= fileInv] + XFile fileInv -> o ["file" .= fileInvitationJSON fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] + XFileAcptInv sharedMsgId fileConnReq fileName -> o ["msgId" .= sharedMsgId, "fileConnReq" .= fileConnReq, "fileName" .= fileName] XInfo profile -> o ["profile" .= profile] XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile] XGrpInv groupInv -> o ["groupInvitation" .= groupInv] @@ -424,3 +470,8 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, p XInfoProbeOk probe -> o ["probe" .= probe] XOk -> JM.empty XUnknown _ ps -> ps + +fileInvitationJSON :: FileInvitation -> J.Object +fileInvitationJSON FileInvitation {fileName, fileSize, fileConnReq} = case fileConnReq of + Nothing -> JM.fromList ["fileName" .= fileName, "fileSize" .= fileSize] + Just fConnReq -> JM.fromList ["fileName" .= fileName, "fileSize" .= fileSize, "fileConnReq" .= fConnReq] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index ed38209ed1..bb10843521 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -52,6 +52,7 @@ module Simplex.Chat.Store getPendingConnections, getContactConnections, getConnectionEntity, + getGroupAndMember, updateConnectionStatus, createNewGroup, createGroupInvitation, @@ -87,8 +88,17 @@ module Simplex.Chat.Store matchReceivedProbeHash, matchSentProbe, mergeContactRecords, - createSndFileTransfer, - createSndGroupFileTransfer, + createSndFileTransfer, -- old file protocol + createSndFileTransferV2, + createSndFileTransferV2Connection, + createSndGroupFileTransfer, -- old file protocol + createSndGroupFileTransferV2, + createSndGroupFileTransferV2Connection, + updateFileCancelled, + updateCIFileStatus, + getSharedMsgIdByFileId, + getFileIdBySharedMsgId, + getGroupFileIdBySharedMsgId, updateSndFileStatus, createSndFileChunk, updateSndFileChunkMsg, @@ -105,6 +115,7 @@ module Simplex.Chat.Store updateFileTransferChatItemId, getFileTransfer, getFileTransferProgress, + getContactFiles, createNewSndMessage, createSndMsgDelivery, createNewMessageAndRcvMsgDelivery, @@ -125,6 +136,7 @@ module Simplex.Chat.Store getGroupChatItemBySharedMsgId, getDirectChatItemIdByText, getGroupChatItemIdByText, + getChatItemByFileId, updateDirectChatItemStatus, updateDirectChatItem, deleteDirectChatItemInternal, @@ -179,10 +191,11 @@ import Simplex.Chat.Migrations.M20220301_smp_servers import Simplex.Chat.Migrations.M20220302_profile_images import Simplex.Chat.Migrations.M20220304_msg_quotes import Simplex.Chat.Migrations.M20220321_chat_item_edited +import Simplex.Chat.Migrations.M20220404_files_status_fields import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (eitherToMaybe) -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) +import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..)) import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction) import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..)) import qualified Simplex.Messaging.Crypto as C @@ -202,7 +215,8 @@ schemaMigrations = ("20220301_smp_servers", m20220301_smp_servers), ("20220302_profile_images", m20220302_profile_images), ("20220304_msg_quotes", m20220304_msg_quotes), - ("20220321_chat_item_edited", m20220321_chat_item_edited) + ("20220321_chat_item_edited", m20220321_chat_item_edited), + ("20220404_files_status_fields", m20220404_files_status_fields) ] -- | The list of migrations in ascending order by date @@ -269,7 +283,7 @@ getUsers st = JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id |] -toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ProfileImage) -> User +toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ImageData) -> User toUser (userId, userContactId, activeUser, displayName, fullName, image) = let profile = Profile {displayName, fullName, image} in User {userId, userContactId, localDisplayName = displayName, profile, activeUser} @@ -482,7 +496,7 @@ updateContact_ db userId contactId displayName newName updatedAt = do (newName, updatedAt, userId, contactId) DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId) -type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage, UTCTime) +type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData, UTCTime) toContact :: ContactRow :. ConnectionRow -> Contact toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) = @@ -758,7 +772,7 @@ getContactRequest_ db userId contactRequestId = |] (userId, contactRequestId) -type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ProfileImage, UTCTime, Maybe XContactId) +type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ImageData, UTCTime, Maybe XContactId) toContactRequest :: ContactRequestRow -> UserContactRequest toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, createdAt, xContactId) = do @@ -1092,7 +1106,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId = WHERE c.user_id = ? AND c.contact_id = ? |] (userId, contactId) - toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime)] -> Either StoreError Contact + toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime)] -> Either StoreError Contact toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt)] = let profile = Profile {displayName, fullName, image} in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} @@ -1139,7 +1153,7 @@ getConnectionEntity st User {userId, userContactId} agentConnId = FROM snd_files s JOIN files f USING (file_id) LEFT JOIN contacts cs USING (contact_id) - LEFT JOIN group_members m USING (group_member_id) + LEFT JOIN group_members m USING (group_member_id) WHERE f.user_id = ? AND f.file_id = ? AND s.connection_id = ? |] (userId, fileId, connId) @@ -1165,6 +1179,47 @@ getConnectionEntity st User {userId, userContactId} agentConnId = userContact_ [Only cReq] = Right UserContact {userContactLinkId, connReqContact = cReq} userContact_ _ = Left SEUserContactLinkNotFound +getGroupAndMember :: StoreMonad m => SQLiteStore -> User -> Int64 -> m (GroupInfo, GroupMember) +getGroupAndMember st User {userId, userContactId} groupMemberId = + liftIOEither . withTransaction st $ \db -> + firstRow toGroupAndMember (SEInternalError "referenced group member not found") $ + DB.query + db + [sql| + SELECT + -- GroupInfo + g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at, + -- GroupInfo {membership} + mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category, + mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id, + -- GroupInfo {membership = GroupMember {memberProfile}} + pu.display_name, pu.full_name, pu.image, + -- from GroupMember + m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, + m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image, + c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, + c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at + FROM group_members m + JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id + JOIN groups g ON g.group_id = m.group_id + JOIN group_profiles gp USING (group_profile_id) + JOIN group_members mu ON g.group_id = mu.group_id + JOIN contact_profiles pu ON pu.contact_profile_id = mu.contact_profile_id + LEFT JOIN connections c ON c.connection_id = ( + SELECT max(cc.connection_id) + FROM connections cc + where cc.group_member_id = m.group_member_id + ) + WHERE m.group_member_id = ? AND g.user_id = ? AND mu.contact_id = ? + |] + (groupMemberId, userId, userContactId) + where + toGroupAndMember :: (GroupInfoRow :. GroupMemberRow :. MaybeConnectionRow) -> (GroupInfo, GroupMember) + toGroupAndMember (groupInfoRow :. memberRow :. connRow) = + let groupInfo = toGroupInfo userContactId groupInfoRow + member = toGroupMember userContactId memberRow + in (groupInfo, (member :: GroupMember) {activeConn = toMaybeConnection connRow}) + updateConnectionStatus :: MonadUnliftIO m => SQLiteStore -> Connection -> ConnStatus -> m () updateConnectionStatus st Connection {connId} connStatus = liftIO . withTransaction st $ \db -> do @@ -1286,7 +1341,7 @@ getGroupInfoByName st user gName = gId <- ExceptT $ getGroupIdByName_ db user gName ExceptT $ getGroupInfo_ db user gId -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ProfileImage, UTCTime) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ImageData, UTCTime) :. GroupMemberRow toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt) :. userMemberRow) = @@ -1344,9 +1399,9 @@ getGroupInvitation st user localDisplayName = findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId) findFromContact _ = const Nothing -type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage) +type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ImageData) -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ProfileImage) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ImageData) toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) = @@ -1724,21 +1779,21 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} = |] (userId, groupMemberId) where - toContact' :: [(Int64, ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact + toContact' :: [(Int64, ContactName, Text, Text, Maybe ImageData, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt) :. connRow] = let profile = Profile {displayName, fullName, image} activeConn = toConnection connRow in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt} toContact' _ = Nothing -createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m SndFileTransfer -createSndFileTransfer st userId Contact {contactId, localDisplayName = recipientDisplayName} filePath FileInvitation {fileName, fileSize} acId chunkSize = +createSndFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> ConnId -> Integer -> m Int64 +createSndFileTransfer st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} acId chunkSize = liftIO . withTransaction st $ \db -> do currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, contactId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) fileId <- insertedRowId db Connection {connId} <- createSndFileConnection_ db userId fileId acId let fileStatus = FSNew @@ -1746,7 +1801,27 @@ createSndFileTransfer st userId Contact {contactId, localDisplayName = recipient db "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" (fileId, fileStatus, connId, currentTs, currentTs) - pure SndFileTransfer {fileId, fileName, filePath, fileSize, chunkSize, recipientDisplayName, connId, fileStatus, agentConnId = AgentConnId acId} + pure fileId + +createSndFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> FilePath -> FileInvitation -> Integer -> m Int64 +createSndFileTransferV2 st userId Contact {contactId} filePath FileInvitation {fileName, fileSize} chunkSize = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, contact_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) + insertedRowId db + +createSndFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> m () +createSndFileTransferV2Connection st userId fileId acId = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + Connection {connId} <- createSndFileConnection_ db userId fileId acId + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, created_at, updated_at) VALUES (?,?,?,?,?)" + (fileId, FSAccepted, connId, currentTs, currentTs) createSndGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> [(GroupMember, ConnId, FileInvitation)] -> FilePath -> Integer -> Integer -> m Int64 createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize chunkSize = @@ -1755,8 +1830,8 @@ createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize ch currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" - (userId, groupId, fileName, filePath, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) fileId <- insertedRowId db forM_ ms $ \(GroupMember {groupMemberId}, agentConnId, _) -> do Connection {connId} <- createSndFileConnection_ db userId fileId agentConnId @@ -1766,6 +1841,80 @@ createSndGroupFileTransfer st userId GroupInfo {groupId} ms filePath fileSize ch (fileId, FSNew, connId, groupMemberId, currentTs, currentTs) pure fileId +createSndGroupFileTransferV2 :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupInfo -> FilePath -> FileInvitation -> Integer -> m Int64 +createSndGroupFileTransferV2 st userId GroupInfo {groupId} filePath FileInvitation {fileName, fileSize} chunkSize = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute + db + "INSERT INTO files (user_id, group_id, file_name, file_path, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)" + (userId, groupId, fileName, filePath, fileSize, chunkSize, CIFSSndStored, currentTs, currentTs) + insertedRowId db + +createSndGroupFileTransferV2Connection :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> ConnId -> GroupMember -> m () +createSndGroupFileTransferV2Connection st userId fileId acId GroupMember {groupMemberId} = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + Connection {connId} <- createSndFileConnection_ db userId fileId acId + DB.execute + db + "INSERT INTO snd_files (file_id, file_status, connection_id, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" + (fileId, FSAccepted, connId, groupMemberId, currentTs, currentTs) + +updateFileCancelled :: MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> m () +updateFileCancelled st userId fileId = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET cancelled = 1, updated_at = ? WHERE user_id = ? AND file_id = ?" (currentTs, userId, fileId) + +updateCIFileStatus :: MsgDirectionI d => MonadUnliftIO m => SQLiteStore -> UserId -> Int64 -> CIFileStatus d -> m () +updateCIFileStatus st userId fileId ciFileStatus = + liftIO . withTransaction st $ \db -> do + currentTs <- getCurrentTime + DB.execute db "UPDATE files SET ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" (ciFileStatus, currentTs, userId, fileId) + +getSharedMsgIdByFileId :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m SharedMsgId +getSharedMsgIdByFileId st userId fileId = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly (SESharedMsgIdNotFoundByFileId fileId) $ + DB.query + db + [sql| + SELECT i.shared_msg_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + |] + (userId, fileId) + +getFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getFileIdBySharedMsgId st userId contactId sharedMsgId = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ + DB.query + db + [sql| + SELECT f.file_id + FROM files f + JOIN chat_items i ON i.chat_item_id = f.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? AND i.shared_msg_id = ? + |] + (userId, contactId, sharedMsgId) + +getGroupFileIdBySharedMsgId :: StoreMonad m => SQLiteStore -> Int64 -> UserId -> SharedMsgId -> m Int64 +getGroupFileIdBySharedMsgId st userId groupId sharedMsgId = + liftIOEither . withTransaction st $ \db -> + firstRow fromOnly (SEFileIdNotFoundBySharedMsgId sharedMsgId) $ + DB.query + db + [sql| + SELECT f.file_id + FROM files f + JOIN chat_items i ON i.chat_item_id = f.chat_item_id + WHERE i.user_id = ? AND i.group_id = ? AND i.shared_msg_id = ? + |] + (userId, groupId, sharedMsgId) + createSndFileConnection_ :: DB.Connection -> UserId -> Int64 -> ConnId -> IO Connection createSndFileConnection_ db userId fileId agentConnId = do currentTs <- getCurrentTime @@ -1835,14 +1984,14 @@ createRcvFileTransfer st userId Contact {contactId, localDisplayName = c} f@File currentTs <- getCurrentTime DB.execute db - "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, created_at, updated_at) VALUES (?,?,?,?,?,?,?)" - (userId, contactId, fileName, fileSize, chunkSize, currentTs, currentTs) + "INSERT INTO files (user_id, contact_id, file_name, file_size, chunk_size, ci_file_status, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)" + (userId, contactId, fileName, fileSize, chunkSize, CIFSRcvInvitation, currentTs, currentTs) fileId <- insertedRowId db DB.execute db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, created_at, updated_at) VALUES (?,?,?,?,?)" (fileId, FSNew, fileConnReq, currentTs, currentTs) - pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize} + pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Nothing} createRcvGroupFileTransfer :: MonadUnliftIO m => SQLiteStore -> UserId -> GroupMember -> FileInvitation -> Integer -> m RcvFileTransfer createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localDisplayName = c} f@FileInvitation {fileName, fileSize, fileConnReq} chunkSize = @@ -1857,7 +2006,7 @@ createRcvGroupFileTransfer st userId GroupMember {groupId, groupMemberId, localD db "INSERT INTO rcv_files (file_id, file_status, file_queue_info, group_member_id, created_at, updated_at) VALUES (?,?,?,?,?,?)" (fileId, FSNew, fileConnReq, groupMemberId, currentTs, currentTs) - pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize} + pure RcvFileTransfer {fileId, fileInvitation = f, fileStatus = RFSNew, senderDisplayName = c, chunkSize, cancelled = False, grpMemberId = Just groupMemberId} getRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> m RcvFileTransfer getRcvFileTransfer st userId fileId = @@ -1870,8 +2019,8 @@ getRcvFileTransfer_ db userId fileId = <$> DB.query db [sql| - SELECT r.file_status, r.file_queue_info, f.file_name, - f.file_size, f.chunk_size, cs.local_display_name, m.local_display_name, + SELECT r.file_status, r.file_queue_info, r.group_member_id, f.file_name, + f.file_size, f.chunk_size, f.cancelled, cs.local_display_name, m.local_display_name, f.file_path, c.connection_id, c.agent_conn_id FROM rcv_files r JOIN files f USING (file_id) @@ -1883,16 +2032,16 @@ getRcvFileTransfer_ db userId fileId = (userId, fileId) where rcvFileTransfer :: - [(FileStatus, AConnectionRequestUri, String, Integer, Integer, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> + [(FileStatus, Maybe ConnReqInvitation, Maybe Int64, String, Integer, Integer, Maybe Bool, Maybe ContactName, Maybe ContactName, Maybe FilePath, Maybe Int64, Maybe AgentConnId)] -> Either StoreError RcvFileTransfer - rcvFileTransfer [(fileStatus', fileConnReq, fileName, fileSize, chunkSize, contactName_, memberName_, filePath_, connId_, agentConnId_)] = + rcvFileTransfer [(fileStatus', fileConnReq, grpMemberId, fileName, fileSize, chunkSize, cancelled_, contactName_, memberName_, filePath_, connId_, agentConnId_)] = let fileInv = FileInvitation {fileName, fileSize, fileConnReq} fileInfo = (filePath_, connId_, agentConnId_) in case contactName_ <|> memberName_ of Nothing -> Left $ SERcvFileInvalid fileId Just name -> case fileStatus' of - FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, senderDisplayName = name, chunkSize} + FSNew -> Right RcvFileTransfer {fileId, fileInvitation = fileInv, fileStatus = RFSNew, senderDisplayName = name, chunkSize, cancelled, grpMemberId} FSAccepted -> ft name fileInv RFSAccepted fileInfo FSConnected -> ft name fileInv RFSConnected fileInfo FSComplete -> ft name fileInv RFSComplete fileInfo @@ -1903,6 +2052,7 @@ getRcvFileTransfer_ db userId fileId = let fileStatus = rfs RcvFileInfo {filePath, connId, agentConnId} in Right RcvFileTransfer {..} _ -> Left $ SERcvFileInvalid fileId + cancelled = fromMaybe False cancelled_ rcvFileTransfer _ = Left $ SERcvFileNotFound fileId acceptRcvFileTransfer :: StoreMonad m => SQLiteStore -> UserId -> Int64 -> ConnId -> FilePath -> m () @@ -1911,8 +2061,8 @@ acceptRcvFileTransfer st userId fileId agentConnId filePath = currentTs <- getCurrentTime DB.execute db - "UPDATE files SET file_path = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" - (filePath, currentTs, userId, fileId) + "UPDATE files SET file_path = ?, ci_file_status = ?, updated_at = ? WHERE user_id = ? AND file_id = ?" + (filePath, CIFSRcvTransfer, currentTs, userId, fileId) DB.execute db "UPDATE rcv_files SET file_status = ?, updated_at = ? WHERE file_id = ?" @@ -1996,7 +2146,8 @@ getFileTransferProgress st userId fileId = ft <- ExceptT $ getFileTransfer_ db userId fileId liftIO $ (ft,) . map fromOnly <$> case ft of - FTSnd _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) + FTSnd _ [] -> pure [Only 0] + FTSnd _ _ -> DB.query db "SELECT COUNT(*) FROM snd_file_chunks WHERE file_id = ? and chunk_sent = 1 GROUP BY connection_id" (Only fileId) FTRcv _ -> DB.query db "SELECT COUNT(*) FROM rcv_file_chunks WHERE file_id = ? AND chunk_stored = 1" (Only fileId) getFileTransfer_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError FileTransfer) @@ -2014,7 +2165,13 @@ getFileTransfer_ db userId fileId = (userId, fileId) where fileTransfer :: [(Maybe Int64, Maybe Int64)] -> IO (Either StoreError FileTransfer) - fileTransfer ((Just _, Nothing) : _) = FTSnd <$$> getSndFileTransfers_ db userId fileId + fileTransfer [(Nothing, Nothing)] = runExceptT $ do + fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId + pure FTSnd {fileTransferMeta, sndFileTransfers = []} + fileTransfer ((Just _, Nothing) : _) = runExceptT $ do + fileTransferMeta <- ExceptT $ getFileTransferMeta_ db userId fileId + sndFileTransfers <- ExceptT $ getSndFileTransfers_ db userId fileId + pure FTSnd {fileTransferMeta, sndFileTransfers} fileTransfer [(Nothing, Just _)] = FTRcv <$$> getRcvFileTransfer_ db userId fileId fileTransfer _ = pure . Left $ SEFileNotFound fileId @@ -2043,6 +2200,35 @@ getSndFileTransfers_ db userId fileId = Just recipientDisplayName -> Right SndFileTransfer {fileId, fileStatus, fileName, fileSize, chunkSize, filePath, recipientDisplayName, connId, agentConnId} Nothing -> Left $ SESndFileInvalid fileId +getFileTransferMeta_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError FileTransferMeta) +getFileTransferMeta_ db userId fileId = + firstRow fileTransferMeta (SEFileNotFound fileId) $ + DB.query + db + [sql| + SELECT f.file_name, f.file_size, f.chunk_size, f.file_path, f.cancelled + FROM files f + WHERE f.user_id = ? AND f.file_id = ? + |] + (userId, fileId) + where + fileTransferMeta :: (String, Integer, Integer, FilePath, Maybe Bool) -> FileTransferMeta + fileTransferMeta (fileName, fileSize, chunkSize, filePath, cancelled_) = + FileTransferMeta {fileId, fileName, filePath, fileSize, chunkSize, cancelled = fromMaybe False cancelled_} + +getContactFiles :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [(Int64, ACIFileStatus, Maybe FilePath)] +getContactFiles st userId Contact {contactId} = + liftIO . withTransaction st $ \db -> + DB.query + db + [sql| + SELECT f.file_id, f.ci_file_status, f.file_path + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE i.user_id = ? AND i.contact_id = ? + |] + (userId, contactId) + createNewSndMessage :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage) -> m SndMessage createNewSndMessage st gVar connOrGroupId mkMessage = liftIOEither . withTransaction st $ \db -> @@ -2348,6 +2534,8 @@ getDirectChatPreviews_ db User {userId} = do COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM contacts ct @@ -2361,6 +2549,7 @@ getDirectChatPreviews_ db User {userId} = do ) MaxIds ON MaxIds.contact_id = ct.contact_id LEFT JOIN chat_items i ON i.contact_id = MaxIds.contact_id AND i.chat_item_id = MaxIds.MaxId + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN ( SELECT contact_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items @@ -2410,6 +2599,8 @@ getGroupChatPreviews_ db User {userId, userContactId} = do COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0), -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- Maybe GroupMember - sender m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2432,6 +2623,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do ) MaxIds ON MaxIds.group_id = g.group_id LEFT JOIN chat_items i ON i.group_id = MaxIds.group_id AND i.chat_item_id = MaxIds.MaxId + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN ( SELECT group_id, COUNT(1) AS UnreadCount, MIN(chat_item_id) AS MinUnread FROM chat_items @@ -2503,9 +2695,12 @@ getDirectChatLast_ db User {userId} contactId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.item_deleted != 1 ORDER BY i.chat_item_id DESC @@ -2531,9 +2726,12 @@ getDirectChatAfter_ db User {userId} contactId afterChatItemId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id > ? AND i.item_deleted != 1 ORDER BY i.chat_item_id ASC @@ -2559,9 +2757,12 @@ getDirectChatBefore_ db User {userId} contactId beforeChatItemId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id < ? AND i.item_deleted != 1 ORDER BY i.chat_item_id DESC @@ -2659,6 +2860,8 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2670,6 +2873,7 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -2699,6 +2903,8 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2710,6 +2916,7 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -2739,6 +2946,8 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -2750,6 +2959,7 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -2974,9 +3184,12 @@ getDirectChatItem_ db userId contactId itemId = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- DirectQuote ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id WHERE i.user_id = ? AND i.contact_id = ? AND i.chat_item_id = ? |] @@ -3101,6 +3314,8 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do SELECT -- ChatItem i.chat_item_id, i.item_ts, i.item_content, i.item_text, i.item_status, i.shared_msg_id, i.item_deleted, i.item_edited, i.created_at, + -- CIFile + f.file_id, f.file_name, f.file_size, f.file_path, f.ci_file_status, -- GroupMember m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status, m.invited_by, m.local_display_name, m.contact_id, @@ -3112,6 +3327,7 @@ getGroupChatItem_ db User {userId, userContactId} groupId itemId = do rm.member_status, rm.invited_by, rm.local_display_name, rm.contact_id, rp.display_name, rp.full_name, rp.image FROM chat_items i + LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members m ON m.group_member_id = i.group_member_id LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id LEFT JOIN chat_items ri ON i.quoted_shared_msg_id = ri.shared_msg_id @@ -3166,6 +3382,35 @@ getGroupChatItemIdByText st User {userId, localDisplayName = userName} groupId c |] (userId, groupId, cName, quotedMsg <> "%") +getChatItemByFileId :: StoreMonad m => SQLiteStore -> User -> Int64 -> m AChatItem +getChatItemByFileId st user@User {userId} fileId = do + liftIOEither . withTransaction st $ \db -> runExceptT $ do + r <- ExceptT $ getChatItemIdByFileId_ db userId fileId + case r of + (itemId, Just contactId, Nothing) -> do + ct <- ExceptT $ getContact_ db userId contactId + (CChatItem msgDir ci) <- ExceptT $ getDirectChatItem_ db userId contactId itemId + pure $ AChatItem SCTDirect msgDir (DirectChat ct) ci + (itemId, Nothing, Just groupId) -> do + gInfo <- ExceptT $ getGroupInfo_ db user groupId + (CChatItem msgDir ci) <- ExceptT $ getGroupChatItem_ db user groupId itemId + pure $ AChatItem SCTGroup msgDir (GroupChat gInfo) ci + _ -> throwError $ SEChatItemNotFoundByFileId fileId + +getChatItemIdByFileId_ :: DB.Connection -> UserId -> Int64 -> IO (Either StoreError (ChatItemId, Maybe Int64, Maybe Int64)) +getChatItemIdByFileId_ db userId fileId = + firstRow id (SEChatItemNotFoundByFileId fileId) $ + DB.query + db + [sql| + SELECT i.chat_item_id, i.contact_id, i.group_id + FROM chat_items i + JOIN files f ON f.chat_item_id = i.chat_item_id + WHERE f.user_id = ? AND f.file_id = ? + LIMIT 1 + |] + (userId, fileId) + updateDirectChatItemsRead :: (StoreMonad m) => SQLiteStore -> Int64 -> (ChatItemId, ChatItemId) -> m () updateDirectChatItemsRead st contactId (fromItemId, toItemId) = do currentTs <- liftIO getCurrentTime @@ -3195,20 +3440,14 @@ type ChatStatsRow = (Int, ChatItemId) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId) = ChatStats {unreadCount, minUnreadItemId} -type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) +type MaybeCIFIleRow = (Maybe Int64, Maybe String, Maybe Integer, Maybe FilePath, Maybe ACIFileStatus) -type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) +type ChatItemRow = (Int64, ChatItemTs, ACIContent, Text, ACIStatus, Maybe SharedMsgId, Bool, Maybe Bool, UTCTime) :. MaybeCIFIleRow + +type MaybeChatItemRow = (Maybe Int64, Maybe ChatItemTs, Maybe ACIContent, Maybe Text, Maybe ACIStatus, Maybe SharedMsgId, Maybe Bool, Maybe Bool, Maybe UTCTime) :. MaybeCIFIleRow type QuoteRow = (Maybe ChatItemId, Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool) --- type DirectChatItemRow = ChatItemRow :. DirectQuoteRow - --- type MaybeDirectChatItemRow = MaybeChatItemRow :. DirectQuoteRow - --- toQuoteData :: QuoteDataRow -> Maybe CIQuoteData --- toQuoteData (quotedItemId, quotedSentAt, quotedMsgContent) = --- CIQuoteData quotedItemId <$> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) - toDirectQuote :: QuoteRow -> Maybe (CIQuote 'CTDirect) toDirectQuote qr@(_, _, _, _, quotedSent) = toQuote qr $ direction <$> quotedSent where @@ -3219,22 +3458,33 @@ toQuote (quotedItemId, quotedSharedMsgId, quotedSentAt, quotedMsgContent, _) dir CIQuote <$> dir <*> pure quotedItemId <*> pure quotedSharedMsgId <*> quotedSentAt <*> quotedMsgContent <*> (parseMaybeMarkdownList . msgContentText <$> quotedMsgContent) toDirectChatItem :: TimeZone -> UTCTime -> ChatItemRow :. QuoteRow -> Either StoreError (CChatItem 'CTDirect) -toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow) = - case (itemContent, itemStatus) of - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus) -> Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus) -> Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent +toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. quoteRow) = + case (itemContent, itemStatus, fileStatus_) of + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Just (AFS SMDSnd fileStatus)) -> + Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, Nothing) -> + Right $ cItem SMDSnd CIDirectSnd ciStatus ciContent Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just (AFS SMDRcv fileStatus)) -> + Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Nothing) -> + Right $ cItem SMDRcv CIDirectRcv ciStatus ciContent Nothing _ -> badItem where - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> CChatItem 'CTDirect - cItem d chatDir ciStatus content = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow} + maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) + maybeCIFile fileStatus = + case (fileId_, fileName_, fileSize_) of + (Just fileId, Just fileName, Just fileSize) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus} + _ -> Nothing + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect + cItem d chatDir ciStatus content file = + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt toDirectChatItemList :: TimeZone -> UTCTime -> MaybeChatItemRow :. QuoteRow -> [CChatItem 'CTDirect] -toDirectChatItemList tz currentTs ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. quoteRow) = - either (const []) (: []) $ toDirectChatItem tz currentTs ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. quoteRow) +toDirectChatItemList tz currentTs (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. quoteRow) = + either (const []) (: []) $ toDirectChatItem tz currentTs (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. quoteRow) toDirectChatItemList _ _ _ = [] type GroupQuoteRow = QuoteRow :. MaybeGroupMemberRow @@ -3250,24 +3500,35 @@ toGroupQuote qr@(_, _, _, _, quotedSent) quotedMember_ = toQuote qr $ direction direction _ _ = Nothing toGroupChatItem :: TimeZone -> UTCTime -> Int64 -> ChatItemRow :. MaybeGroupMemberRow :. GroupQuoteRow -> Either StoreError (CChatItem 'CTGroup) -toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do +toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. (fileId_, fileName_, fileSize_, filePath, fileStatus_)) :. memberRow_ :. quoteRow :. quotedMemberRow_) = do let member_ = toMaybeGroupMember userContactId memberRow_ let quotedMember_ = toMaybeGroupMember userContactId quotedMemberRow_ - case (itemContent, itemStatus, member_) of - (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _) -> Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent quotedMember_ - (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member) -> Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent quotedMember_ + case (itemContent, itemStatus, member_, fileStatus_) of + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Just (AFS SMDSnd fileStatus)) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent quotedMember_ (maybeCIFile fileStatus) + (ACIContent SMDSnd ciContent, ACIStatus SMDSnd ciStatus, _, Nothing) -> + Right $ cItem SMDSnd CIGroupSnd ciStatus ciContent quotedMember_ Nothing + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Just (AFS SMDRcv fileStatus)) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent quotedMember_ (maybeCIFile fileStatus) + (ACIContent SMDRcv ciContent, ACIStatus SMDRcv ciStatus, Just member, Nothing) -> + Right $ cItem SMDRcv (CIGroupRcv member) ciStatus ciContent quotedMember_ Nothing _ -> badItem where - cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe GroupMember -> CChatItem 'CTGroup - cItem d chatDir ciStatus content quotedMember_ = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_} + maybeCIFile :: CIFileStatus d -> Maybe (CIFile d) + maybeCIFile fileStatus = + case (fileId_, fileName_, fileSize_) of + (Just fileId, Just fileName, Just fileSize) -> Just CIFile {fileId, fileName, fileSize, filePath, fileStatus} + _ -> Nothing + cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe GroupMember -> Maybe (CIFile d) -> CChatItem 'CTGroup + cItem d chatDir ciStatus content quotedMember_ file = + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta d ciMeta content status = mkCIMeta itemId content itemText status sharedMsgId itemDeleted (fromMaybe False itemEdited) tz currentTs itemTs createdAt toGroupChatItemList :: TimeZone -> UTCTime -> Int64 -> MaybeGroupChatItemRow -> [CChatItem 'CTGroup] -toGroupChatItemList tz currentTs userContactId ((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) = - either (const []) (: []) $ toGroupChatItem tz currentTs userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. memberRow_ :. quoteRow :. quotedMemberRow_) +toGroupChatItemList tz currentTs userContactId (((Just itemId, Just itemTs, Just itemContent, Just itemText, Just itemStatus, sharedMsgId, Just itemDeleted, itemEdited, Just createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) = + either (const []) (: []) $ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, itemContent, itemText, itemStatus, sharedMsgId, itemDeleted, itemEdited, createdAt) :. fileRow) :. memberRow_ :. quoteRow :. quotedMemberRow_) toGroupChatItemList _ _ _ _ = [] getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer] @@ -3380,6 +3641,8 @@ data StoreError | SERcvFileNotFound {fileId :: FileTransferId} | SEFileNotFound {fileId :: FileTransferId} | SERcvFileInvalid {fileId :: FileTransferId} + | SESharedMsgIdNotFoundByFileId {fileId :: FileTransferId} + | SEFileIdNotFoundBySharedMsgId {sharedMsgId :: SharedMsgId} | SEConnectionNotFound {agentConnId :: AgentConnId} | SEIntroNotFound | SEUniqueID @@ -3389,6 +3652,7 @@ data StoreError | SEChatItemNotFound {itemId :: ChatItemId} | SEQuotedChatItemNotFound | SEChatItemSharedMsgIdNotFound {sharedMsgId :: SharedMsgId} + | SEChatItemNotFoundByFileId {fileId :: FileTransferId} deriving (Show, Exception, Generic) instance ToJSON StoreError where diff --git a/src/Simplex/Chat/Terminal.hs b/src/Simplex/Chat/Terminal.hs index 1daab90035..4b1a6b8a4a 100644 --- a/src/Simplex/Chat/Terminal.hs +++ b/src/Simplex/Chat/Terminal.hs @@ -3,43 +3,23 @@ module Simplex.Chat.Terminal where -import Control.Logger.Simple import Control.Monad.Except -import Control.Monad.Reader -import Simplex.Chat import Simplex.Chat.Controller +import Simplex.Chat.Core import Simplex.Chat.Help (chatWelcome) import Simplex.Chat.Options -import Simplex.Chat.Store import Simplex.Chat.Terminal.Input import Simplex.Chat.Terminal.Notification import Simplex.Chat.Terminal.Output -import Simplex.Chat.Types (User) import Simplex.Messaging.Util (raceAny_) -import UnliftIO (async, waitEither_) -simplexChat :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () -simplexChat cfg@ChatConfig {dbPoolSize, yesToMigrations} opts t - | logAgent opts = do - setLogLevel LogInfo -- LogError - withGlobalLogging logCfg initRun - | otherwise = initRun - where - initRun = do - sendNotification' <- initializeNotifications - let f = chatStoreFile $ dbFilePrefix opts - st <- createStore f dbPoolSize yesToMigrations - u <- getCreateActiveUser st - ct <- newChatTerminal t - cc <- newChatController st (Just u) cfg opts sendNotification' - runSimplexChat u ct cc - -runSimplexChat :: User -> ChatTerminal -> ChatController -> IO () -runSimplexChat u ct cc = do - when (firstTime cc) . printToTerminal ct $ chatWelcome u - a1 <- async $ runChatTerminal ct cc - a2 <- runReaderT (startChatController u) cc - waitEither_ a1 a2 +simplexChatTerminal :: WithTerminal t => ChatConfig -> ChatOpts -> t -> IO () +simplexChatTerminal cfg opts t = do + sendToast <- initializeNotifications + simplexChatCore cfg opts (Just sendToast) $ \u cc -> do + ct <- newChatTerminal t + when (firstTime cc) . printToTerminal ct $ chatWelcome u + runChatTerminal ct cc runChatTerminal :: ChatTerminal -> ChatController -> IO () runChatTerminal ct cc = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc, runInputLoop ct cc] diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index cc7aa47cf5..5e8f4bcd74 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -1,14 +1,14 @@ +{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE FlexibleContexts #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} module Simplex.Chat.Terminal.Input where import Control.Monad.Except import Control.Monad.Reader -import qualified Data.ByteString.Char8 as B -import Data.Char (isSpace) import Data.List (dropWhileEnd) import qualified Data.Text as T import Data.Text.Encoding (encodeUtf8) @@ -16,8 +16,8 @@ import Simplex.Chat import Simplex.Chat.Controller import Simplex.Chat.Styled import Simplex.Chat.Terminal.Output +import Simplex.Chat.Util (safeDecodeUtf8) import Simplex.Chat.View -import Simplex.Messaging.Parsers (parseAll) import System.Exit (exitSuccess) import System.Terminal hiding (insertChars) import UnliftIO.STM @@ -33,7 +33,7 @@ runInputLoop :: ChatTerminal -> ChatController -> IO () runInputLoop ct cc = forever $ do s <- atomically . readTBQueue $ inputQ cc let bs = encodeUtf8 $ T.pack s - cmd = parseAll chatCommandP $ B.dropWhileEnd isSpace bs + cmd = parseChatCommand bs unless (isMessage cmd) $ echo s r <- runReaderT (execChatCommand bs) cc case r of @@ -47,7 +47,9 @@ runInputLoop ct cc = forever $ do Right SendMessage {} -> True Right SendGroupMessage {} -> True Right SendFile {} -> True + Right SendFileInv {} -> True Right SendGroupFile {} -> True + Right SendGroupFileInv {} -> True Right SendMessageQuote {} -> True Right SendGroupMessageQuote {} -> True Right SendMessageBroadcast {} -> True @@ -92,7 +94,7 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition Leftwards -> setPosition leftPos Rightwards -> setPosition rightPos Upwards - | ms == mempty && null s -> let s' = previousInput ts in ts' (s', length s') + | ms == mempty && null s -> let s' = upArrowCmd $ previousInput ts in ts' (s', length s') | ms == mempty -> let p' = p - tw in if p' > 0 then setPosition p' else ts | otherwise -> ts Downwards @@ -133,6 +135,14 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition | ms == ctrlKey = nextWordPos | ms == altKey = nextWordPos | otherwise = p + upArrowCmd inp = case parseChatCommand . encodeUtf8 $ T.pack inp of + Left _ -> inp + Right cmd -> case cmd of + SendMessage {} -> "! " <> inp + SendGroupMessage {} -> "! " <> inp + SendMessageQuote {contactName, message} -> T.unpack $ "! @" <> contactName <> " " <> safeDecodeUtf8 message + SendGroupMessageQuote {groupName, message} -> T.unpack $ "! #" <> groupName <> " " <> safeDecodeUtf8 message + _ -> inp setPosition p' = ts' (s, p') prevWordPos | p == 0 || null s = p diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 54ad6267fc..03eda5f6f5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -31,7 +31,7 @@ import Database.SQLite.Simple.Internal (Field (..)) import Database.SQLite.Simple.Ok (Ok (Ok)) import Database.SQLite.Simple.ToField (ToField (..)) import GHC.Generics (Generic) -import Simplex.Messaging.Agent.Protocol (AConnectionRequestUri, ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) +import Simplex.Messaging.Agent.Protocol (ConnId, ConnectionMode (..), ConnectionRequestUri, InvitationId) import Simplex.Messaging.Agent.Store.SQLite (fromTextField_) import Simplex.Messaging.Encoding.String import Simplex.Messaging.Parsers (dropPrefix, sumTypeJSON) @@ -171,7 +171,7 @@ groupName' GroupInfo {localDisplayName = g} = g data Profile = Profile { displayName :: ContactName, fullName :: Text, - image :: Maybe ProfileImage + image :: Maybe ImageData } deriving (Eq, Show, Generic, FromJSON) @@ -182,7 +182,7 @@ instance ToJSON Profile where data GroupProfile = GroupProfile { displayName :: GroupName, fullName :: Text, - image :: Maybe ProfileImage + image :: Maybe ImageData } deriving (Eq, Show, Generic, FromJSON) @@ -190,19 +190,19 @@ instance ToJSON GroupProfile where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} -newtype ProfileImage = ProfileImage Text +newtype ImageData = ImageData Text deriving (Eq, Show) -instance FromJSON ProfileImage where - parseJSON = fmap ProfileImage . J.parseJSON +instance FromJSON ImageData where + parseJSON = fmap ImageData . J.parseJSON -instance ToJSON ProfileImage where - toJSON (ProfileImage t) = J.toJSON t - toEncoding (ProfileImage t) = J.toEncoding t +instance ToJSON ImageData where + toJSON (ImageData t) = J.toJSON t + toEncoding (ImageData t) = J.toEncoding t -instance ToField ProfileImage where toField (ProfileImage t) = toField t +instance ToField ImageData where toField (ImageData t) = toField t -instance FromField ProfileImage where fromField = fmap ProfileImage . fromField +instance FromField ImageData where fromField = fmap ImageData . fromField data GroupInvitation = GroupInvitation { fromMember :: MemberIdRole, @@ -522,7 +522,7 @@ type FileTransferId = Int64 data FileInvitation = FileInvitation { fileName :: String, fileSize :: Integer, - fileConnReq :: AConnectionRequestUri + fileConnReq :: Maybe ConnReqInvitation } deriving (Eq, Show, Generic, FromJSON) @@ -533,7 +533,9 @@ data RcvFileTransfer = RcvFileTransfer fileInvitation :: FileInvitation, fileStatus :: RcvFileStatus, senderDisplayName :: ContactName, - chunkSize :: Integer + chunkSize :: Integer, + cancelled :: Bool, + grpMemberId :: Maybe Int64 } deriving (Eq, Show, Generic, FromJSON) @@ -601,13 +603,34 @@ instance FromField AgentInvId where fromField f = AgentInvId <$> fromField f instance ToField AgentInvId where toField (AgentInvId m) = toField m -data FileTransfer = FTSnd {sndFileTransfers :: [SndFileTransfer]} | FTRcv RcvFileTransfer +data FileTransfer + = FTSnd + { fileTransferMeta :: FileTransferMeta, + sndFileTransfers :: [SndFileTransfer] + } + | FTRcv {rcvFileTransfer :: RcvFileTransfer} deriving (Show, Generic) instance ToJSON FileTransfer where toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "FT" toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "FT" +data FileTransferMeta = FileTransferMeta + { fileId :: FileTransferId, + fileName :: String, + filePath :: String, + fileSize :: Integer, + chunkSize :: Integer, + cancelled :: Bool + } + deriving (Eq, Show, Generic) + +instance ToJSON FileTransferMeta where toEncoding = J.genericToEncoding J.defaultOptions + +fileTransferCancelled :: FileTransfer -> Bool +fileTransferCancelled (FTSnd FileTransferMeta {cancelled} _) = cancelled +fileTransferCancelled (FTRcv RcvFileTransfer {cancelled}) = cancelled + data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show) instance FromField FileStatus where fromField = fromTextField_ decodeText diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index f6cd89f824..9a725a9936 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -46,6 +46,7 @@ responseToView testView = \case CRChatRunning -> [] CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats] CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat] + CRApiParsedMarkdown ft -> [plain . bshow $ J.encode ft] CRUserSMPServers smpServers -> viewSMPServers smpServers testView CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item CRChatItemStatusUpdated _ -> [] @@ -92,14 +93,14 @@ responseToView testView = \case CRRcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath -> ["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath] CRRcvFileAcceptedSndCancelled ft -> viewRcvFileSndCancelled ft - CRSndGroupFileCancelled fts -> viewSndGroupFileCancelled fts + CRSndGroupFileCancelled ftm fts -> viewSndGroupFileCancelled ftm fts CRRcvFileCancelled ft -> receivingFile_ "cancelled" ft CRUserProfileUpdated p p' -> viewUserProfileUpdated p p' CRContactUpdated c c' -> viewContactUpdated c c' CRContactsMerged intoCt mergedCt -> viewContactsMerged intoCt mergedCt CRReceivedContactRequest UserContactRequest {localDisplayName = c, profile} -> viewReceivedContactRequest c profile CRRcvFileStart ft -> receivingFile_ "started" ft - CRRcvFileComplete ft -> receivingFile_ "completed" ft + CRRcvFileComplete ci -> receivingFile_' "completed" ci CRRcvFileSndCancelled ft -> viewRcvFileSndCancelled ft CRSndFileStart ft -> sendingFile_ "started" ft CRSndFileComplete ft -> sendingFile_ "completed" ft @@ -155,54 +156,69 @@ responseToView testView = \case testViewChat :: AChat -> [StyledString] testViewChat (AChat _ Chat {chatItems}) = [sShow $ map toChatView chatItems] where - toChatView :: CChatItem c -> ((Int, Text), Maybe (Int, Text)) - toChatView (CChatItem dir ChatItem {meta, quotedItem}) = - ((msgDirectionInt $ toMsgDirection dir, itemText meta),) $ case quotedItem of - Nothing -> Nothing - Just CIQuote {chatDir = quoteDir, content} -> - Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content) + toChatView :: CChatItem c -> ((Int, Text), Maybe (Int, Text), Maybe String) + toChatView (CChatItem dir ChatItem {meta, quotedItem, file}) = + ((msgDirectionInt $ toMsgDirection dir, itemText meta), qItem, fPath) + where + qItem = case quotedItem of + Nothing -> Nothing + Just CIQuote {chatDir = quoteDir, content} -> + Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content) + fPath = case file of + Just CIFile {filePath = Just fp} -> Just fp + _ -> Nothing viewErrorsSummary :: [a] -> StyledString -> [StyledString] viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)] viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] -viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of +viewChatItem chat ChatItem {chatDir, meta, content, quotedItem, file} = case chat of DirectChat c -> case chatDir of CIDirectSnd -> case content of - CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] - CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta where to = ttyToContact' c CIDirectRcv -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft where from = ttyFromContact' c where quote = maybe [] (directQuote chatDir) quotedItem GroupChat g -> case chatDir of CIGroupSnd -> case content of - CISndMsgContent mc -> viewSentMessage to quote mc meta + CISndMsgContent mc -> withSndFile to $ sndMsg to quote mc CISndDeleted _ -> [] - CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta where to = ttyToGroup g CIGroupRcv m -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> withRcvFile from $ rcvMsg from quote mc CIRcvDeleted _ -> [] - CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft where from = ttyFromGroup' g m where quote = maybe [] (groupQuote g) quotedItem _ -> [] + where + sndMsg to quote mc = case (msgContentText mc, file) of + ("", Just _) -> [] + _ -> viewSentMessage to quote mc meta + withSndFile to l = case file of + -- TODO pass CIFile + Just CIFile {fileId, filePath = Just fPath} -> l <> viewSentFileInvitation to fileId fPath meta + _ -> l + rcvMsg from quote mc = case (msgContentText mc, file) of + ("", Just _) -> [] + _ -> viewReceivedMessage from quote mc meta + withRcvFile from l = case file of + Just f -> l <> viewReceivedFileInvitation from f meta + _ -> l viewItemUpdate :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString] viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of DirectChat Contact {localDisplayName = c} -> case chatDir of CIDirectRcv -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta _ -> [] where from = ttyFromContactEdited c @@ -210,7 +226,7 @@ viewItemUpdate chat ChatItem {chatDir, meta, content, quotedItem} = case chat of CIDirectSnd -> ["message updated"] GroupChat g -> case chatDir of CIGroupRcv GroupMember {localDisplayName = m} -> case content of - CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc + CIRcvMsgContent mc -> viewReceivedMessage from quote mc meta _ -> [] where from = ttyFromGroupEdited g m @@ -222,13 +238,13 @@ viewItemDelete :: ChatInfo c -> ChatItem c d -> ChatItem c' d' -> [StyledString] viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} ChatItem {content = toContent} = case chat of DirectChat Contact {localDisplayName = c} -> case (chatDir, deletedContent, toContent) of (CIDirectRcv, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of - CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] meta mc + CIDMBroadcast -> viewReceivedMessage (ttyFromContactDeleted c) [] mc meta CIDMInternal -> ["message deleted"] (CIDirectSnd, _, _) -> ["message deleted"] _ -> [] GroupChat g -> case (chatDir, deletedContent, toContent) of (CIGroupRcv GroupMember {localDisplayName = m}, CIRcvMsgContent mc, CIRcvDeleted mode) -> case mode of - CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] meta mc + CIDMBroadcast -> viewReceivedMessage (ttyFromGroupDeleted g m) [] mc meta CIDMInternal -> ["message deleted"] (CIGroupSnd, _, _) -> ["message deleted"] _ -> [] @@ -433,8 +449,8 @@ viewContactUpdated where fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName' -viewReceivedMessage :: StyledString -> [StyledString] -> CIMeta d -> MsgContent -> [StyledString] -viewReceivedMessage from quote meta = receivedWithTime_ from quote meta . ttyMsgContent +viewReceivedMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString] +viewReceivedMessage from quote mc meta = receivedWithTime_ from quote meta (ttyMsgContent mc) receivedWithTime_ :: StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString] receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do @@ -453,7 +469,7 @@ receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do in styleTime $ formatTime defaultTimeLocale format localTime viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString] -viewSentMessage to quote mc = sentWithTime_ . prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc) +viewSentMessage to quote mc = sentWithTime_ (prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc)) where indent = if null quote then "" else " " @@ -487,11 +503,11 @@ viewRcvFileSndCancelled :: RcvFileTransfer -> [StyledString] viewRcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} = [ttyContact c <> " cancelled sending " <> rcvFile ft] -viewSndGroupFileCancelled :: [SndFileTransfer] -> [StyledString] -viewSndGroupFileCancelled fts = +viewSndGroupFileCancelled :: FileTransferMeta -> [SndFileTransfer] -> [StyledString] +viewSndGroupFileCancelled FileTransferMeta {fileId, fileName} fts = case filter (\SndFileTransfer {fileStatus = s} -> s /= FSCancelled && s /= FSComplete) fts of - [] -> ["sending file can't be cancelled"] - ts@(ft : _) -> ["cancelled sending " <> sndFile ft <> " to " <> listMembers ts] + [] -> ["cancelled sending " <> fileTransferStr fileId fileName] + ts -> ["cancelled sending " <> fileTransferStr fileId fileName <> " to " <> listRecipients ts] sendingFile_ :: StyledString -> SndFileTransfer -> [StyledString] sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = @@ -500,11 +516,22 @@ sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} = sndFile :: SndFileTransfer -> StyledString sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName -viewReceivedFileInvitation :: StyledString -> CIMeta d -> RcvFileTransfer -> [StyledString] -viewReceivedFileInvitation from meta ft = receivedWithTime_ from [] meta (receivedFileInvitation_ ft) +viewReceivedFileInvitation :: StyledString -> CIFile d -> CIMeta d -> [StyledString] +viewReceivedFileInvitation from file meta = receivedWithTime_ from [] meta (receivedFileInvitation_ file) -receivedFileInvitation_ :: RcvFileTransfer -> [StyledString] -receivedFileInvitation_ RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} = +receivedFileInvitation_ :: CIFile d -> [StyledString] +receivedFileInvitation_ CIFile {fileId, fileName, fileSize} = + [ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)", + -- below is printed for auto-accepted files as well; auto-accept is disabled in terminal though so in reality it never happens + "use " <> highlight ("/fr " <> show fileId <> " [/ | ]") <> " to receive it" + ] + +-- TODO remove +viewReceivedFileInvitation' :: StyledString -> RcvFileTransfer -> CIMeta d -> [StyledString] +viewReceivedFileInvitation' from RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} meta = receivedWithTime_ from [] meta (receivedFileInvitation_' fileId fileName fileSize) + +receivedFileInvitation_' :: Int64 -> String -> Integer -> [StyledString] +receivedFileInvitation_' fileId fileName fileSize = [ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)", "use " <> highlight ("/fr " <> show fileId <> " [/ | ]") <> " to receive it" ] @@ -521,6 +548,13 @@ humanReadableSize size mB = kB * 1024 gB = mB * 1024 +receivingFile_' :: StyledString -> AChatItem -> [StyledString] +receivingFile_' status (AChatItem _ _ (DirectChat Contact {localDisplayName = c}) ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIDirectRcv}) = + [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact c] +receivingFile_' status (AChatItem _ _ _ ChatItem {file = Just CIFile {fileId, fileName}, chatDir = CIGroupRcv GroupMember {localDisplayName = m}}) = + [status <> " receiving " <> fileTransferStr fileId fileName <> " from " <> ttyContact m] +receivingFile_' status _ = [status <> " receiving file"] -- shouldn't happen + receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString] receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} = [status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c] @@ -532,24 +566,20 @@ fileTransferStr :: Int64 -> String -> StyledString fileTransferStr fileId fileName = "file " <> sShow fileId <> " (" <> ttyFilePath fileName <> ")" viewFileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString] -viewFileTransferStatus (FTSnd [ft@SndFileTransfer {fileStatus, fileSize, chunkSize}], chunksNum) = - ["sending " <> sndFile ft <> " " <> sndStatus] - where - sndStatus = case fileStatus of - FSNew -> "not accepted yet" - FSAccepted -> "just started" - FSConnected -> "progress " <> fileProgress chunksNum chunkSize fileSize - FSComplete -> "complete" - FSCancelled -> "cancelled" -viewFileTransferStatus (FTSnd [], _) = ["no file transfers (empty group)"] -viewFileTransferStatus (FTSnd fts@(ft : _), chunksNum) = - case concatMap membersTransferStatus $ groupBy ((==) `on` fs) $ sortOn fs fts of - [membersStatus] -> ["sending " <> sndFile ft <> " " <> membersStatus] - membersStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) membersStatuses +viewFileTransferStatus (FTSnd FileTransferMeta {fileId, fileName, cancelled} [], _) = + [ "sending " <> fileTransferStr fileId fileName <> ": no file transfers" + <> if cancelled then ", file transfer cancelled" else "" + ] +viewFileTransferStatus (FTSnd FileTransferMeta {cancelled} fts@(ft : _), chunksNum) = + recipientStatuses <> ["file transfer cancelled" | cancelled] where + recipientStatuses = + case concatMap recipientsTransferStatus $ groupBy ((==) `on` fs) $ sortOn fs fts of + [recipientsStatus] -> ["sending " <> sndFile ft <> " " <> recipientsStatus] + recipientsStatuses -> ("sending " <> sndFile ft <> ": ") : map (" " <>) recipientsStatuses fs = fileStatus :: SndFileTransfer -> FileStatus - membersTransferStatus [] = [] - membersTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listMembers ts] + recipientsTransferStatus [] = [] + recipientsTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listRecipients ts] where sndStatus = case fileStatus of FSNew -> "not accepted" @@ -567,8 +597,8 @@ viewFileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileI RFSComplete RcvFileInfo {filePath} -> "complete, path: " <> plain filePath RFSCancelled RcvFileInfo {filePath} -> "cancelled, received part path: " <> plain filePath -listMembers :: [SndFileTransfer] -> StyledString -listMembers = mconcat . intersperse ", " . map (ttyContact . recipientDisplayName) +listRecipients :: [SndFileTransfer] -> StyledString +listRecipients = mconcat . intersperse ", " . map (ttyContact . recipientDisplayName) fileProgress :: [Integer] -> Integer -> Integer -> StyledString fileProgress chunksNum chunkSize fileSize = @@ -621,6 +651,7 @@ viewChatError = \case SEDuplicateContactLink -> ["you already have chat address, to show: " <> highlight' "/sa"] SEUserContactLinkNotFound -> ["no chat address, to create: " <> highlight' "/ad"] SEContactRequestNotFoundByName c -> ["no contact request from " <> ttyContact c] + SEFileIdNotFoundBySharedMsgId _ -> [] -- recipient tried to accept cancelled file SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity SEQuotedChatItemNotFound -> ["message not found - reply is not sent"] e -> ["chat db error: " <> sShow e] diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index b27780430d..57143e7d8f 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -18,6 +18,7 @@ import qualified Data.Text as T import Network.Socket import Simplex.Chat import Simplex.Chat.Controller (ChatConfig (..), ChatController (..)) +import Simplex.Chat.Core import Simplex.Chat.Options import Simplex.Chat.Store import Simplex.Chat.Terminal @@ -46,7 +47,9 @@ opts = { dbFilePrefix = undefined, smpServers = ["smp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:5001"], logConnections = False, - logAgent = False + logAgent = False, + chatCmd = "", + chatCmdDelay = 3 } termSettings :: VirtualTerminalSettings @@ -83,8 +86,8 @@ virtualSimplexChat dbFilePrefix profile = do Right user <- runExceptT $ createUser st profile True t <- withVirtualTerminal termSettings pure ct <- newChatTerminal t - cc <- newChatController st (Just user) cfg opts {dbFilePrefix} (const $ pure ()) -- no notifications - chatAsync <- async $ runSimplexChat user ct cc + cc <- newChatController st (Just user) cfg opts {dbFilePrefix} Nothing -- no notifications + chatAsync <- async . runSimplexChat user cc . const $ runChatTerminal ct termQ <- newTQueueIO termAsync <- async $ readTerminalOutput t termQ pure TestCC {chatController = cc, virtualTerminal = t, chatAsync, termAsync, termQ} @@ -125,6 +128,7 @@ testChatN ps test = withTmpFiles $ do test tcs concurrentlyN_ $ map ( [TestCC] -> IO [TestCC] getTestCCs [] tcs = pure tcs getTestCCs ((p, db) : envs') tcs = (:) <$> virtualSimplexChat db p <*> getTestCCs envs' tcs @@ -135,6 +139,7 @@ getTermLine :: TestCC -> IO String getTermLine = atomically . readTQueue . termQ -- Use code below to echo virtual terminal +-- getTermLine :: TestCC -> IO String -- getTermLine cc = do -- s <- atomically . readTQueue $ termQ cc -- name <- userName cc diff --git a/tests/ChatTests.hs b/tests/ChatTests.hs index ad0e238e04..dc49a12808 100644 --- a/tests/ChatTests.hs +++ b/tests/ChatTests.hs @@ -13,16 +13,16 @@ import qualified Data.ByteString as B import Data.Char (isDigit) import qualified Data.Text as T import Simplex.Chat.Controller (ChatController (..)) -import Simplex.Chat.Types (Profile (..), ProfileImage (..), User (..)) +import Simplex.Chat.Types (ImageData (..), Profile (..), User (..)) import Simplex.Chat.Util (unlessM) -import System.Directory (doesFileExist) +import System.Directory (copyFile, doesFileExist) import Test.Hspec aliceProfile :: Profile aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing} bobProfile :: Profile -bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ProfileImage "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC")} +bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ImageData "data:image/png;base64,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")} cathProfile :: Profile cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing} @@ -57,6 +57,21 @@ chatTests = do it "sender cancelled file transfer" testFileSndCancel it "recipient cancelled file transfer" testFileRcvCancel it "send and receive file to group" testGroupFileTransfer + describe "sending and receiving files v2" $ do + it "send and receive file" testFileTransferV2 + it "send and receive a small file" testSmallFileTransferV2 + it "sender cancelled file transfer" testFileSndCancelV2 + it "recipient cancelled file transfer" testFileRcvCancelV2 + it "send and receive file to group" testGroupFileTransferV2 + describe "messages with files" $ do + it "send and receive message with file" testMessageWithFile + it "send and receive image" testSendImage + it "files folder: send and receive image" testFilesFoldersSendImage + it "files folder: sender deleted file during transfer" testFilesFoldersImageSndDelete + it "files folder: recipient deleted file during transfer" testFilesFoldersImageRcvDelete + it "send and receive image with text and quote" testSendImageWithTextAndQuote + it "send and receive image to group" testGroupSendImage + it "send and receive image with text and quote to group" testGroupSendImageWithTextAndQuote describe "user contact link" $ do it "create and connect via contact link" testUserContactLink it "auto accept contact requests" testUserContactLinkAutoAccept @@ -233,7 +248,7 @@ testDirectMessageDelete = alice #$> ("/_get chat @2 count=100", chat, []) alice #$> ("/_update item @2 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send_quote @2 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send @2 quoted 1 text quoting deleted message", id, "cannot reply to this message") bob #$> ("/_update item @2 2 text hey alice", id, "message updated") alice <# "bob> [edited] hey alice" @@ -823,7 +838,7 @@ testGroupMessageDelete = cath #$> ("/_get chat #1 count=100", chat, [(0, "hello!")]) alice #$> ("/_update item #1 1 text updating deleted message", id, "cannot update this item") - alice #$> ("/_send_quote #1 1 text quoting deleted message", id, "cannot reply to this message") + alice #$> ("/_send #1 quoted 1 text quoting deleted message", id, "cannot reply to this message") threadDelay 1000000 -- msg id 2 @@ -995,7 +1010,8 @@ testFileSndCancel = [ do alice <## "cancelled sending file 1 (test.jpg) to bob" alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled", + alice <## "sending file 1 (test.jpg) cancelled: bob" + alice <## "file transfer cancelled", do bob <## "alice cancelled sending file 1 (test.jpg)" bob ##> "/fs 1" @@ -1021,11 +1037,9 @@ testFileRcvCancel = do alice <## "bob cancelled receiving file 1 (test.jpg)" alice ##> "/fs 1" - alice <## "sending file 1 (test.jpg) cancelled" + alice <## "sending file 1 (test.jpg) cancelled: bob" ] checkPartialTransfer - where - waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f testGroupFileTransfer :: IO () testGroupFileTransfer = @@ -1070,6 +1084,410 @@ testGroupFileTransfer = cath <## "completed receiving file 1 (test.jpg) from alice" ] +testFileTransferV2 :: IO () +testFileTransferV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + startFileTransferV2 alice bob + concurrentlyN_ + [ do + bob #> "@alice receiving here..." + bob <## "completed receiving file 1 (test.jpg) from alice", + do + alice <# "bob> receiving here..." + alice <## "completed sending file 1 (test.jpg) to bob" + ] + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + +testSmallFileTransferV2 :: IO () +testSmallFileTransferV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice `send` "/f_v2 @bob ./tests/fixtures/test.txt" + alice <# "/f @bob ./tests/fixtures/test.txt" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.txt (11 bytes / 11 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.txt" + concurrentlyN_ + [ do + bob <## "started receiving file 1 (test.txt) from alice" + bob <## "completed receiving file 1 (test.txt) from alice", + do + alice <## "started sending file 1 (test.txt) to bob" + alice <## "completed sending file 1 (test.txt) to bob" + ] + src <- B.readFile "./tests/fixtures/test.txt" + dest <- B.readFile "./tests/tmp/test.txt" + dest `shouldBe` src + +testFileSndCancelV2 :: IO () +testFileSndCancelV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + startFileTransferV2 alice bob + alice ##> "/fc 1" + concurrentlyN_ + [ do + alice <## "cancelled sending file 1 (test.jpg) to bob" + alice ##> "/fs 1" + alice <## "sending file 1 (test.jpg) cancelled: bob" + alice <## "file transfer cancelled", + do + bob <## "alice cancelled sending file 1 (test.jpg)" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg" + ] + checkPartialTransfer + +testFileRcvCancelV2 :: IO () +testFileRcvCancelV2 = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + startFileTransferV2 alice bob + bob ##> "/fs 1" + getTermLine bob >>= (`shouldStartWith` "receiving file 1 (test.jpg) progress") + waitFileExists "./tests/tmp/test.jpg" + bob ##> "/fc 1" + concurrentlyN_ + [ do + bob <## "cancelled receiving file 1 (test.jpg) from alice" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.jpg) cancelled, received part path: ./tests/tmp/test.jpg", + do + alice <## "bob cancelled receiving file 1 (test.jpg)" + alice ##> "/fs 1" + alice <## "sending file 1 (test.jpg) cancelled: bob" + ] + checkPartialTransfer + +testGroupFileTransferV2 :: IO () +testGroupFileTransferV2 = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice `send` "/f_v2 #team ./tests/fixtures/test.jpg" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + alice ##> "/fs 1" + getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg): no file transfers") + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob" + alice ##> "/fs 1" + alice <## "sending file 1 (test.jpg) complete: bob", + do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + ] + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to cath" + alice <## "completed sending file 1 (test.jpg) to cath" + alice ##> "/fs 1" + getTermLine alice >>= (`shouldStartWith` "sending file 1 (test.jpg) complete"), + do + cath <## "started receiving file 1 (test.jpg) from alice" + cath <## "completed receiving file 1 (test.jpg) from alice" + ] + +testMessageWithFile :: IO () +testMessageWithFile = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/_send @2 file ./tests/fixtures/test.jpg text hi, sending a file" + alice <# "@bob hi, sending a file" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> hi, sending a file" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chatF, [((1, "hi, sending a file"), Just "./tests/fixtures/test.jpg")]) + bob #$> ("/_get chat @2 count=100", chatF, [((0, "hi, sending a file"), Just "./tests/tmp/test.jpg")]) + +testSendImage :: IO () +testSendImage = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice ##> "/_send @2 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chatF, [((1, ""), Just "./tests/fixtures/test.jpg")]) + bob #$> ("/_get chat @2 count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) + -- deleting contact without files folder set should not remove file + bob ##> "/d alice" + bob <## "alice: contact is deleted" + fileExists <- doesFileExist "./tests/tmp/test.jpg" + fileExists `shouldBe` True + +testFilesFoldersSendImage :: IO () +testFilesFoldersSendImage = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #$> ("/_files_folder ./tests/fixtures", id, "ok") + bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") + alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + bob <## "saving file 1 from alice to test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/app_files/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chatF, [((1, ""), Just "test.jpg")]) + bob #$> ("/_get chat @2 count=100", chatF, [((0, ""), Just "test.jpg")]) + -- deleting contact with files folder set should remove file + checkActionDeletesFile "./tests/tmp/app_files/test.jpg" $ do + bob ##> "/d alice" + bob <## "alice: contact is deleted" + +testFilesFoldersImageSndDelete :: IO () +testFilesFoldersImageSndDelete = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #$> ("/_files_folder ./tests/tmp/alice_app_files", id, "ok") + copyFile "./tests/fixtures/test.jpg" "./tests/tmp/alice_app_files/test.jpg" + bob #$> ("/_files_folder ./tests/tmp/bob_app_files", id, "ok") + alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + bob <## "saving file 1 from alice to test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + -- deleting contact should cancel and remove file + checkActionDeletesFile "./tests/tmp/alice_app_files/test.jpg" $ do + alice ##> "/d bob" + alice <## "bob: contact is deleted" + bob <## "alice cancelled sending file 1 (test.jpg)" + bob ##> "/fs 1" + bob <## "receiving file 1 (test.jpg) cancelled, received part path: test.jpg" + -- deleting contact should remove cancelled file + checkActionDeletesFile "./tests/tmp/bob_app_files/test.jpg" $ do + bob ##> "/d alice" + bob <## "alice: contact is deleted" + +testFilesFoldersImageRcvDelete :: IO () +testFilesFoldersImageRcvDelete = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #$> ("/_files_folder ./tests/fixtures", id, "ok") + bob #$> ("/_files_folder ./tests/tmp/app_files", id, "ok") + alice ##> "/_send @2 file test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f @bob test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1" + bob <## "saving file 1 from alice to test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + -- deleting contact should cancel and remove file + waitFileExists "./tests/tmp/app_files/test.jpg" + 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 ##> "/fs 1" + alice <## "sending file 1 (test.jpg) cancelled: bob" + +testSendImageWithTextAndQuote :: IO () +testSendImageWithTextAndQuote = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + bob #> "@alice hi alice" + alice <# "bob> hi alice" + alice ##> "/_send @2 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "@bob > hi alice" + alice <## " hey bob" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> > hi alice" + bob <## " hey bob" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + concurrently_ + (bob <## "completed receiving file 1 (test.jpg) from alice") + (alice <## "completed sending file 1 (test.jpg) to bob") + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + alice #$> ("/_get chat @2 count=100", chat'', [((0, "hi alice"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi alice"), Just "./tests/fixtures/test.jpg")]) + alice #$$> ("/_get chats", [("@bob", "hey bob")]) + bob #$> ("/_get chat @2 count=100", chat'', [((1, "hi alice"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi alice"), Just "./tests/tmp/test.jpg")]) + bob #$$> ("/_get chats", [("@alice", "hey bob")]) + +testGroupSendImage :: IO () +testGroupSendImage = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + alice ##> "/_send #1 file ./tests/fixtures/test.jpg json {\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob", + do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + ] + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to cath" + alice <## "completed sending file 1 (test.jpg) to cath", + do + cath <## "started receiving file 1 (test.jpg) from alice" + cath <## "completed receiving file 1 (test.jpg) from alice" + ] + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 `shouldBe` src + alice #$> ("/_get chat #1 count=100", chatF, [((1, ""), Just "./tests/fixtures/test.jpg")]) + bob #$> ("/_get chat #1 count=100", chatF, [((0, ""), Just "./tests/tmp/test.jpg")]) + cath #$> ("/_get chat #1 count=100", chatF, [((0, ""), Just "./tests/tmp/test_1.jpg")]) + +testGroupSendImageWithTextAndQuote :: IO () +testGroupSendImageWithTextAndQuote = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + bob #> "#team hi team" + concurrently_ + (alice <# "#team bob> hi team") + (cath <# "#team bob> hi team") + threadDelay 1000000 + alice ##> "/_send #1 file ./tests/fixtures/test.jpg quoted 1 json {\"text\":\"hey bob\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}" + alice <# "#team > bob hi team" + alice <## " hey bob" + alice <# "/f #team ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + concurrentlyN_ + [ do + bob <# "#team alice> > bob hi team" + bob <## " hey bob" + bob <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it", + do + cath <# "#team alice> > bob hi team" + cath <## " hey bob" + cath <# "#team alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + cath <## "use /fr 1 [/ | ] to receive it" + ] + bob ##> "/fr 1 ./tests/tmp/" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to bob" + alice <## "completed sending file 1 (test.jpg) to bob", + do + bob <## "started receiving file 1 (test.jpg) from alice" + bob <## "completed receiving file 1 (test.jpg) from alice" + ] + cath ##> "/fr 1 ./tests/tmp/" + cath <## "saving file 1 from alice to ./tests/tmp/test_1.jpg" + concurrentlyN_ + [ do + alice <## "started sending file 1 (test.jpg) to cath" + alice <## "completed sending file 1 (test.jpg) to cath", + do + cath <## "started receiving file 1 (test.jpg) from alice" + cath <## "completed receiving file 1 (test.jpg) from alice" + ] + src <- B.readFile "./tests/fixtures/test.jpg" + dest <- B.readFile "./tests/tmp/test.jpg" + dest `shouldBe` src + dest2 <- B.readFile "./tests/tmp/test_1.jpg" + dest2 `shouldBe` src + alice #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((1, "hey bob"), Just (0, "hi team"), Just "./tests/fixtures/test.jpg")]) + alice #$$> ("/_get chats", [("#team", "hey bob"), ("@bob", ""), ("@cath", "")]) + bob #$> ("/_get chat #1 count=100", chat'', [((1, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (1, "hi team"), Just "./tests/tmp/test.jpg")]) + bob #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@cath", "")]) + cath #$> ("/_get chat #1 count=100", chat'', [((0, "hi team"), Nothing, Nothing), ((0, "hey bob"), Just (0, "hi team"), Just "./tests/tmp/test_1.jpg")]) + cath #$$> ("/_get chats", [("#team", "hey bob"), ("@alice", ""), ("@bob", "")]) + testUserContactLink :: IO () testUserContactLink = testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do @@ -1324,6 +1742,19 @@ startFileTransfer alice bob = do (bob <## "started receiving file 1 (test.jpg) from alice") (alice <## "started sending file 1 (test.jpg) to bob") +startFileTransferV2 :: TestCC -> TestCC -> IO () +startFileTransferV2 alice bob = do + alice `send` "/f_v2 @bob ./tests/fixtures/test.jpg" + alice <# "/f @bob ./tests/fixtures/test.jpg" + alice <## "use /fc 1 to cancel sending" + bob <# "alice> sends file test.jpg (136.5 KiB / 139737 bytes)" + bob <## "use /fr 1 [/ | ] to receive it" + bob ##> "/fr 1 ./tests/tmp" + bob <## "saving file 1 from alice to ./tests/tmp/test.jpg" + concurrently_ + (bob <## "started receiving file 1 (test.jpg) from alice") + (alice <## "started sending file 1 (test.jpg) to bob") + checkPartialTransfer :: IO () checkPartialTransfer = do src <- B.readFile "./tests/fixtures/test.jpg" @@ -1331,6 +1762,17 @@ checkPartialTransfer = do B.unpack src `shouldStartWith` B.unpack dest B.length src > B.length dest `shouldBe` True +checkActionDeletesFile :: FilePath -> IO () -> IO () +checkActionDeletesFile file action = do + fileExistsBefore <- doesFileExist file + fileExistsBefore `shouldBe` True + action + fileExistsAfter <- doesFileExist file + fileExistsAfter `shouldBe` False + +waitFileExists :: FilePath -> IO () +waitFileExists f = unlessM (doesFileExist f) $ waitFileExists f + connectUsers :: TestCC -> TestCC -> IO () connectUsers cc1 cc2 = do name1 <- showName cc1 @@ -1418,10 +1860,16 @@ cc #$> (cmd, f, res) = do (f <$> getTermLine cc) `shouldReturn` res chat :: String -> [(Int, String)] -chat = map fst . chat' +chat = map (\(a, _, _) -> a) . chat'' chat' :: String -> [((Int, String), Maybe (Int, String))] -chat' = read +chat' = map (\(a, b, _) -> (a, b)) . chat'' + +chatF :: String -> [((Int, String), Maybe String)] +chatF = map (\(a, _, c) -> (a, c)) . chat'' + +chat'' :: String -> [((Int, String), Maybe (Int, String), Maybe String)] +chat'' = read (#$$>) :: TestCC -> (String, [(String, String)]) -> Expectation cc #$$> (cmd, res) = do diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 2ac9801cb5..9b37be14a9 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -82,36 +82,96 @@ s #==# msg = do s ==# msg testProfile :: Profile -testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ProfileImage "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=")} +testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=")} testGroupProfile :: GroupProfile testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image = Nothing} decodeChatMessageTest :: Spec decodeChatMessageTest = describe "Chat message encoding/decoding" $ do - it "x.msg.new" $ "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" #==# XMsgNew (MCSimple $ MCText "hello") - it "x.msg.new" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCSimple $ MCText "hello")) - it "x.msg.new" $ + it "x.msg.new simple text" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing)) + it "x.msg.new simple link" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"https://simplex.chat\",\"type\":\"link\",\"preview\":{\"description\":\"SimpleX Chat\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA\",\"title\":\"SimpleX Chat\",\"uri\":\"https://simplex.chat\"}}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCLink "https://simplex.chat" $ LinkPreview {uri = "https://simplex.chat", title = "SimpleX Chat", description = "SimpleX Chat", image = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgA"}) Nothing)) + it "x.msg.new simple image" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) + it "x.msg.new simple image with text" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"here's an image\",\"type\":\"image\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCImage "here's an image" $ ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=") Nothing)) + it "x.msg.new chat message " $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + ##==## (ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew (MCSimple (ExtMsgContent (MCText "hello") Nothing)))) + it "x.msg.new quote" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}}}}" ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") - ( XMsgNew $ - MCQuote - ( QuotedMsg - (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) - $ MCText "hello there!" - ) - (MCText "hello to you too") + ( XMsgNew + ( MCQuote + ( QuotedMsg + (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) + $ MCText "hello there!" + ) + ( ExtMsgContent + (MCText "hello to you too") + Nothing + ) + ) ) - it "x.msg.new" $ + it "x.msg.new forward" $ "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true}}" - ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew . MCForward $ MCText "hello") + ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") Nothing)) + it "x.msg.new simple with file" $ + "{\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + #==# XMsgNew (MCSimple (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) + it "x.msg.new quote with file" $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello to you too\",\"type\":\"text\"},\"quote\":{\"content\":{\"text\":\"hello there!\",\"type\":\"text\"},\"msgRef\":{\"msgId\":\"BQYHCA==\",\"sent\":true,\"sentAt\":\"1970-01-01T00:00:01.000000001Z\"}},\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + ##==## ChatMessage + (Just $ SharedMsgId "\1\2\3\4") + ( XMsgNew + ( MCQuote + ( QuotedMsg + (MsgRef (Just $ SharedMsgId "\5\6\7\8") (systemToUTCTime $ MkSystemTime 1 1) True Nothing) + $ MCText "hello there!" + ) + ( ExtMsgContent + (MCText "hello to you too") + (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}) + ) + ) + ) + it "x.msg.new forward with file" $ + "{\"msgId\":\"AQIDBA==\",\"event\":\"x.msg.new\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"forward\":true,\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + ##==## ChatMessage (Just $ SharedMsgId "\1\2\3\4") (XMsgNew $ MCForward (ExtMsgContent (MCText "hello") (Just FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing}))) + it "x.msg.update" $ + "{\"event\":\"x.msg.update\",\"params\":{\"msgId\":\"AQIDBA==\", \"content\":{\"text\":\"hello\",\"type\":\"text\"}}}" + #==# XMsgUpdate (SharedMsgId "\1\2\3\4") (MCText "hello") + it "x.msg.del" $ + "{\"event\":\"x.msg.del\",\"params\":{\"msgId\":\"AQIDBA==\"}}" + #==# XMsgDel (SharedMsgId "\1\2\3\4") + it "x.msg.deleted" $ + "{\"event\":\"x.msg.deleted\",\"params\":{}}" + #==# XMsgDeleted it "x.file" $ "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" - #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = ACR SCMInvitation testConnReq} - it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg" - it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile - it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing} + #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Just testConnReq} + it "x.file without file invitation" $ + "{\"event\":\"x.file\",\"params\":{\"file\":{\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}" + #==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = Nothing} + it "x.file.acpt" $ + "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" + #==# XFileAcpt "photo.jpg" + it "x.file.acpt.inv" $ + "{\"event\":\"x.file.acpt.inv\",\"params\":{\"msgId\":\"AQIDBA==\",\"fileName\":\"photo.jpg\",\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}" + #==# XFileAcptInv (SharedMsgId "\1\2\3\4") testConnReq "photo.jpg" + it "x.info" $ + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" + #==# XInfo testProfile + it "x.info with empty full name" $ + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" + #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing} it "x.contact with xContactId" $ "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") @@ -127,8 +187,9 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.grp.inv" $ "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile} - it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") - it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") + it "x.grp.acpt" $ + "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} @@ -144,12 +205,30 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.grp.mem.info" $ "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile - it "x.grp.mem.con" $ "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemCon (MemberId "\1\2\3\4") - it "x.grp.mem.con.all" $ "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemConAll (MemberId "\1\2\3\4") - it "x.grp.mem.del" $ "{\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemDel (MemberId "\1\2\3\4") - it "x.grp.leave" $ "{\"event\":\"x.grp.leave\",\"params\":{}}" ==# XGrpLeave - it "x.grp.del" $ "{\"event\":\"x.grp.del\",\"params\":{}}" ==# XGrpDel - it "x.info.probe" $ "{\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbe (Probe "\1\2\3\4") - it "x.info.probe.check" $ "{\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" #==# XInfoProbeCheck (ProbeHash "\1\2\3\4") - it "x.info.probe.ok" $ "{\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" #==# XInfoProbeOk (Probe "\1\2\3\4") - it "x.ok" $ "{\"event\":\"x.ok\",\"params\":{}}" ==# XOk + it "x.grp.mem.con" $ + "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpMemCon (MemberId "\1\2\3\4") + it "x.grp.mem.con.all" $ + "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpMemConAll (MemberId "\1\2\3\4") + it "x.grp.mem.del" $ + "{\"event\":\"x.grp.mem.del\",\"params\":{\"memberId\":\"AQIDBA==\"}}" + #==# XGrpMemDel (MemberId "\1\2\3\4") + it "x.grp.leave" $ + "{\"event\":\"x.grp.leave\",\"params\":{}}" + ==# XGrpLeave + it "x.grp.del" $ + "{\"event\":\"x.grp.del\",\"params\":{}}" + ==# XGrpDel + it "x.info.probe" $ + "{\"event\":\"x.info.probe\",\"params\":{\"probe\":\"AQIDBA==\"}}" + #==# XInfoProbe (Probe "\1\2\3\4") + it "x.info.probe.check" $ + "{\"event\":\"x.info.probe.check\",\"params\":{\"probeHash\":\"AQIDBA==\"}}" + #==# XInfoProbeCheck (ProbeHash "\1\2\3\4") + it "x.info.probe.ok" $ + "{\"event\":\"x.info.probe.ok\",\"params\":{\"probe\":\"AQIDBA==\"}}" + #==# XInfoProbeOk (Probe "\1\2\3\4") + it "x.ok" $ + "{\"event\":\"x.ok\",\"params\":{}}" + ==# XOk diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs new file mode 100644 index 0000000000..03ef2a47d9 --- /dev/null +++ b/tests/SchemaDump.hs @@ -0,0 +1,30 @@ +{-# LANGUAGE OverloadedStrings #-} + +module SchemaDump where + +import ChatClient (withTmpFiles) +import Control.Monad (void) +import Simplex.Chat.Store (createStore) +import System.Process (readCreateProcess, shell) +import Test.Hspec + +testDB :: FilePath +testDB = "tests/tmp/test_chat.db" + +schema :: FilePath +schema = "src/Simplex/Chat/Migrations/chat_schema.sql" + +schemaDumpTest :: Spec +schemaDumpTest = + it "verify and overwrite schema dump" testVerifySchemaDump + +testVerifySchemaDump :: IO () +testVerifySchemaDump = + withTmpFiles $ do + void $ createStore testDB 1 False + void $ readCreateProcess (shell $ "touch " <> schema) "" + savedSchema <- readFile schema + savedSchema `seq` pure () + void $ readCreateProcess (shell $ "sqlite3 " <> testDB <> " .schema > " <> schema) "" + currentSchema <- readFile schema + savedSchema `shouldBe` currentSchema diff --git a/tests/Test.hs b/tests/Test.hs index 8ed0ac0dcb..3df06d6c60 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -3,6 +3,7 @@ import ChatTests import MarkdownTests import MobileTests import ProtocolTests +import SchemaDump import Test.Hspec main :: IO () @@ -11,3 +12,4 @@ main = withSmpServer . hspec $ do describe "SimpleX chat protocol" protocolTests describe "Mobile API Tests" mobileTests describe "SimpleX chat client" chatTests + describe "Schema dump" schemaDumpTest