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 - 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!
[](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
[](https://github.com/simplex-chat/simplex-chat/releases)
@@ -19,280 +19,120 @@
[ ](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.
+
+
+
+## :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.

-## 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.
-
-
-
-> **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.
-
-
-
-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.
-
-
-
-### 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.
+
+
+
+> **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.
+
+
+
+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.
+
+
+
+### 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 (/ 100000) tcs
where
+ getTestCCs :: [(Profile, FilePath)] -> [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,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")}
+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