Compare commits

..

4 Commits

Author SHA1 Message Date
Andriy Druk 6d2d7fbf50 Add simple C binding for iOS and Android projects (#120)
* Dev: add simple C binding for iOS and Android projects

* System: add ci script
2021-11-04 07:52:38 +00:00
Evgeny Poberezkin 7eea1a6178 save header file 2021-10-30 16:51:13 +01:00
Evgeny Poberezkin bc69bcb929 commit ios/android stubs 2021-10-30 16:43:19 +01:00
Evgeny Poberezkin 5cba18120b move haskell implementation to a folder (#108)
* move haskell implementation to a folder

* build v5 branch

* fixing CI
2021-10-02 10:10:35 +01:00
200 changed files with 6653 additions and 13276 deletions
-1
View File
@@ -1 +0,0 @@
* @epoberezkin @efim-poberezkin
-1
View File
@@ -1,2 +1 @@
github: simplex-chat
open_collective: simplex-chat
+2 -2
View File
@@ -1,4 +1,4 @@
{
"template": "Commits:\n${{UNCATEGORIZED}}",
"pr_template": "- ${{TITLE}}"
"template": "${{UNCATEGORIZED}}",
"pr_template": "- ${{TITLE}}\n"
}
+18 -40
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- master
- stable
- v5
tags:
- "v*"
pull_request:
@@ -49,15 +49,24 @@ jobs:
include:
- os: ubuntu-20.04
cache_path: ~/.stack
stack_args: "--test"
artifact_rel_path: /bin/simplex-chat
asset_name: simplex-chat-ubuntu-20_04-x86-64
- os: ubuntu-18.04
cache_path: ~/.stack
stack_args: "--test"
artifact_rel_path: /bin/simplex-chat
asset_name: simplex-chat-ubuntu-18_04-x86-64
- os: macos-latest
cache_path: ~/.stack
stack_args: "--test"
artifact_rel_path: /bin/simplex-chat
asset_name: simplex-chat-macos-x86-64
# TODO enable tests for windows once fixed (remove stack_args altogether)
- os: windows-latest
cache_path: C:/sr
stack_args: ""
artifact_rel_path: /bin/simplex-chat.exe
asset_name: simplex-chat-windows-x86-64
steps:
- name: Clone project
@@ -66,7 +75,7 @@ jobs:
- name: Setup Stack
uses: haskell/actions/setup@v1
with:
ghc-version: '8.10.7'
ghc-version: '8.8.4'
enable-stack: true
stack-version: 'latest'
@@ -76,49 +85,18 @@ jobs:
path: ${{ matrix.cache_path }}
key: ${{ matrix.os }}-${{ hashFiles('stack.yaml') }}
# / Unix
- name: Unix build
id: unix_build
if: matrix.os != 'windows-latest'
shell: bash
- name: Build & test
id: build_test
working-directory: ./haskell
run: |
stack build --test
stack build ${{ matrix.stack_args }}
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
- name: Unix upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os != 'windows-latest'
- name: Upload binaries to release
if: startsWith(github.ref, 'refs/tags/v')
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.unix_build.outputs.LOCAL_INSTALL_ROOT }}/bin/simplex-chat
file: ${{ steps.build_test.outputs.LOCAL_INSTALL_ROOT }}${{ matrix.artifact_rel_path }}
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
# Unix /
# / Windows
# * In powershell multiline commands do not fail if individual commands fail - https://github.community/t/multiline-commands-on-windows-do-not-fail-if-individual-commands-fail/16753
# * And GitHub Actions does not support parameterizing shell in a matrix job - https://github.community/t/using-matrix-to-specify-shell-is-it-possible/17065
# * So we're running a separate set of actions for Windows build
# TODO run tests on Windows
- name: Windows build
id: windows_build
if: matrix.os == 'windows-latest'
shell: cmd
run: |
stack build
echo "::set-output name=LOCAL_INSTALL_ROOT::$(stack path --local-install-root)"
- name: Windows upload binary to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.windows_build.outputs.LOCAL_INSTALL_ROOT }}/bin/simplex-chat.exe
asset_name: ${{ matrix.asset_name }}
tag: ${{ github.ref }}
# Windows /
+17
View File
@@ -0,0 +1,17 @@
name: Chat Lib Tests
on: [push]
jobs:
test:
runs-on: ubuntu-18.04
steps:
- uses: actions/checkout@v1
- name: Test
run: cd packages/chat;
cmake . -Bbuild;
cd build;
cmake --build .;
ctest --verbose
-36
View File
@@ -1,36 +0,0 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened, closed, synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request'
# Beta Release
uses: cla-assistant/github-action@v2.1.3-beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN : ${{ secrets.PERSONAL_ACCESS_TOKEN }}
with:
path-to-signatures: 'signatures/v1.1/cla.json'
path-to-document: 'https://github.com/simplex-chat/cla/blob/master/CLA.md'
# branch should not be protected
remote-organization-name: simplex-chat
remote-repository-name: cla
branch: 'master'
# allowlist: user1,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
#create-file-commit-message: 'For example: Creating file for storing CLA Signatures'
#signed-commit-message: 'For example: $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA
+7
View File
@@ -40,6 +40,7 @@ cabal.project.local
cabal.project.local~
.HTF/
.ghc.environment.*
*.cabal
stack.yaml.lock
# Idris
@@ -48,3 +49,9 @@ stack.yaml.lock
# chat database
*.db
*.db.bak
packages/android/.idea
packages/chat/.idea
packages/chat/build
packages/chat/Testing
packages/ios/SimpleX Chat.xcodeproj/project.xcworkspace/xcuserdata
packages/ios/SimpleX Chat.xcodeproj/xcuserdata
+51 -108
View File
@@ -1,28 +1,19 @@
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
<img align="right" src="images/logo.svg" alt="SimpleX logo" height="90">
# SimpleX Chat
# SimpleX chat
SimpleX - the most private and secure open-source chat and applications platform - now with double-ratchet E2E encryption.
## Private, secure, decentralized
[![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
[![GitHub downloads](https://img.shields.io/github/downloads/simplex-chat/simplex-chat/total)](https://github.com/simplex-chat/simplex-chat/releases)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
[![Follow on Twitter](https://img.shields.io/twitter/follow/SimpleXChat?style=social)](https://twitter.com/simplexchat)
[![Join on Reddit](https://img.shields.io/reddit/subreddit-subscribers/SimpleXChat?style=social)](https://www.reddit.com/r/SimpleXChat)
SimpleX Chat is a terminal (command line) UI using [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker.
> **NEW in v0.4: [groups](#groups) and [sending files](#sending-files)!**
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
The motivation for SimpleX chat is [presented here](./simplex.md).
**v1.0.0 is released: [read announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md)**
SimpleX chat prototype is a thin terminal UI on top of [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker that uses [SMP protocols](https://github.com/simplex-chat/simplexmq/blob/master/protocol).
### :zap: Quick installation
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
```
Once the chat client is installed, simply run `simplex-chat` from your terminal.
See [simplex.chat](https://simplex.chat) website for chat demo and the explanations of the system and how SMP protocol works.
![simplex-chat](./images/connection.gif)
@@ -31,10 +22,8 @@ Once the chat client is installed, simply run `simplex-chat` from your terminal.
- [Disclaimer](#disclaimer)
- [Network topology](#network-topology)
- [Terminal chat features](#terminal-chat-features)
- [Installation](#🚀-installation)
- [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)
@@ -43,26 +32,25 @@ Once the chat client is installed, simply run `simplex-chat` from your terminal.
- [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)
- [Future roadmap](#future-roadmap)
- [License](#license)
## Disclaimer
SimpleX Chat implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
This is WIP implementation of SimpleX chat that implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
[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.
If you expect a software being reliable most of the time and doing something useful, then this is probably not ready for you yet. We do use it for terminal chat though, and it seems to work most of the time - we would really appreciate if you try it and give us your feedback.
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.
**Please note:** The main differentiation of SimpleX network is the approach to internet message routing rather than encryption; for that reason no sufficient attention was paid to either TCP transport level encryption or to E2E encryption protocols - they are implemented in an ad hoc way based on RSA and AES algorithms. See [SMP protocol](https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a) on TCP transport encryption protocol (AEAD-GCM scheme, with an AES key negotiation based on RSA key hash known to the client in advance) and [this section](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2021-01-26-crypto.md#e2e-encryption) on E2E encryption protocol (an ad hoc hybrid scheme a la PGP). These protocols will change in a consumer ready version to something more robust.
## 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.
SimpleX is a decentralized client-server network that uses redundant, disposable nodes to asynchronously pass the 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.
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 - it avoids the problem of metadata visibility that federated networks suffer from 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 RSA 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.
@@ -71,49 +59,31 @@ The routing of messages relies on the knowledge of client devices how user conta
- 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)).
- E2E encryption, with RSA public key that has to be passed out-of-band (see [How to use SimpleX chat](#how-to-use-simplex-chat)).
- Message signing and verification with automatically generated RSA keys.
- 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.
- Authentication of each command/message by SMP servers with automatically generated RSA key pairs.
- TCP transport encryption using SMP transport protocol.
Public keys involved in key exchange are not used as identity, they are randomly generated for each contact.
RSA keys 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.
<a name="🚀-installation"></a>
## 🚀 Installation
## Installation
### Download chat client
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.
#### 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 <binary>
mv <binary> ~/.local/bin/simplex-chat
```
(or any other preferred location on `PATH`).
(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).
@@ -125,8 +95,6 @@ move <binary> %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):
@@ -134,11 +102,10 @@ On Linux, you can build the chat executable using [docker build with custom outp
```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)).
> **Please note:** If you encounter ``version `GLIBC_2.28' not found`` error, rebuild it with `haskell:8.8.4-stretch` base image (change it in your local [Dockerfile](Dockerfile)).
#### Using Haskell stack
@@ -153,7 +120,6 @@ and build the project:
```shell
$ git clone git@github.com:simplex-chat/simplex-chat.git
$ cd simplex-chat
$ git checkout stable
$ stack install
```
@@ -163,7 +129,7 @@ $ stack install
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.
By default, app data directory is created in the home directory (`~/.simplex`, or `%APPDATA%/simplex` on Windows), and two SQLite database files `simplex.chat.db` and `simplex.agent.db` are initialized in it.
To specify a different file path prefix for the database files use `-d` command line option:
@@ -171,17 +137,17 @@ To specify a different file path prefix for the database files use `-d` command
$ simplex-chat -d alice
```
Running above, for example, would create `alice_v1_chat.db` and `alice_v1_agent.db` database files in current directory.
Running above, for example, would create `alice.chat.db` and `alice.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).
Default SMP servers are hosted on Linode (London, UK and Fremont, CA) - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L40). Base-64 encoded string after server host is the transport key digest.
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
$ simplex-chat -s smp.example.com:5223#KXNE1m2E1m0lm92WGKet9CL6+lO742Vy5G6nsrkvgs8=
```
Base64url encoded string preceding the server address is the server's offline certificate fingerprint which is validated by client during TLS handshake.
The base-64 encoded string in server address is the digest of RSA transport handshake key that the server will generate on the first run and output its digest.
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).
@@ -189,9 +155,9 @@ 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.
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. In case different contacts chose the same display name, the chat client adds a numeric suffix to their local display names.
The diagram below shows how to connect and message a contact:
This diagram shows how to connect and message a contact:
<div align="center">
<img align="center" src="images/how-to-use-simplex.svg">
@@ -201,7 +167,7 @@ Once you've set up your local profile, enter `/c` (for `/connect`) to create a n
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 invitation has the format `smp::<server>::<queue_id>::<rsa_public_key_for_this_queue_only>`. 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.
The contact who received the invitation should enter `/c <invitation>` to accept the connection. This establishes the connection, and both parties are notified.
@@ -211,7 +177,7 @@ Use `/help` in chat to see the list of available commands.
### Groups
To create a group use `/g <group>`, then add contacts to it with `/a <group> <name>`. You can then send messages to the group by entering `#<group> <message>`. Use `/help groups` for other commands.
To create a group use `/g <group>`, then add contacts to it with `/a <group> <name>`and send messages with `#<group> <message>`. Use `/help groups` for other commands.
![simplex-chat](./images/groups.gif)
@@ -225,62 +191,39 @@ You can send a file to your contact with `/f @<contact> <file_path>` - the recip
You can send files to a group with `/f #<group> <file_path>`.
### 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 <user_contact_address>`.
You can accept or reject incoming requests with `/ac <name>` and `/rc <name>` commands.
User address is "long-term" in a sense that it is a multiple-use connection link - it can be used until it is deleted by the user, in which case all established connections would still remain active (unlike how it works with email, when changing the address results in people not being able to message you).
Use `/help address` for other commands.
![simplex-chat](./images/user-addresses.gif)
### Access chat history
SimpleX chat stores all your contacts and conversations in a local SQLite database, making it private and portable by design, owned and controlled by user.
> 🚧 **Section currently out of date - will be updated soon** 🏗
You can view and search your chat history by querying your database. Run the below script to create message views in your database.
SimpleX chat stores all your contacts and conversations in a local database file, making it private and portable by design, fully owned and controlled by you.
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/message_views.sql | sqlite3 ~/.simplex/simplex_v1_chat.db
You can search your chat history via SQLite database file:
```
sqlite3 ~/.simplex/smp-chat.db
```
Open SQLite Command Line Shell:
Now you can query `messages` table, for example:
```sh
sqlite3 ~/.simplex/simplex_v1_chat.db
```sql
select * from messages
where conn_alias = cast('alice' as blob)
and body like '%cats%'
order by internal_id desc;
```
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;
```
## Roadmap
## Future roadmap
1. Mobile and desktop apps (in progress).
2. SMP protocol improvements:
- SMP queue redundancy and rotation.
- Message delivery confirmation.
- Support multiple devices.
- 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.
- 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.
-1
View File
@@ -1 +0,0 @@
SimpleX
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="GRADLE" />
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
</project>
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DesignSurface">
<option name="filePathToZoomLevelMap">
<map>
<entry key="app/src/main/res/drawable-v24/ic_launcher_foreground.xml" value="0.2328125" />
<entry key="app/src/main/res/drawable/ic_launcher_background.xml" value="0.2328125" />
<entry key="app/src/main/res/layout/activity_main.xml" value="0.22010869565217392" />
</map>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_11" default="true" project-jdk-name="Android Studio default JDK" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="chat.simplex.app">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -1,72 +0,0 @@
#include <jni.h>
// from the RTS
void hs_init(int * argc, char **argv[]);
// from android-support
void setLineBuffering(void);
int pipe_std_to_socket(const char * name);
JNIEXPORT jint JNICALL
Java_chat_simplex_app_MainActivityKt_pipeStdOutToSocket(JNIEnv *env, __unused jclass clazz, jstring socket_name) {
const char *name = (*env)->GetStringUTFChars(env, socket_name, JNI_FALSE);
int ret = pipe_std_to_socket(name);
(*env)->ReleaseStringUTFChars(env, socket_name, name);
return ret;
}
JNIEXPORT void JNICALL
Java_chat_simplex_app_MainActivityKt_initHS(__unused JNIEnv *env, __unused jclass clazz) {
hs_init(NULL, NULL);
setLineBuffering();
}
// from simplex-chat
typedef void* chat_store;
typedef void* controller;
extern chat_store chat_init_store(const char * path);
extern char *chat_get_user(chat_store store);
extern char *chat_create_user(chat_store store, const char *data);
extern controller chat_start(chat_store store);
extern char *chat_send_cmd(controller ctl, const char *cmd);
extern char *chat_recv_msg(controller ctl);
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_MainActivityKt_chatInit(JNIEnv *env, __unused jclass clazz, jstring datadir) {
const char *_data = (*env)->GetStringUTFChars(env, datadir, JNI_FALSE);
jlong res = (jlong)chat_init_store(_data);
(*env)->ReleaseStringUTFChars(env, datadir, _data);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatGetUser(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_get_user((void*)controller));
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatCreateUser(JNIEnv *env, __unused jclass clazz, jlong controller, jstring data) {
const char *_data = (*env)->GetStringUTFChars(env, data, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_create_user((void*)controller, _data));
(*env)->ReleaseStringUTFChars(env, data, _data);
return res;
}
JNIEXPORT jlong JNICALL
Java_chat_simplex_app_MainActivityKt_chatStart(JNIEnv *env, jclass clazz, jlong controller) {
return (jlong)chat_start((void*)controller);
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatSendCmd(JNIEnv *env, __unused jclass clazz, jlong controller, jstring msg) {
const char *_msg = (*env)->GetStringUTFChars(env, msg, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_send_cmd((void*)controller, _msg));
(*env)->ReleaseStringUTFChars(env, msg, _msg);
return res;
}
JNIEXPORT jstring JNICALL
Java_chat_simplex_app_MainActivityKt_chatRecvMsg(JNIEnv *env, __unused jclass clazz, jlong controller) {
return (*env)->NewStringUTF(env, chat_recv_msg((void*)controller));
}
@@ -1,123 +0,0 @@
package chat.simplex.app
import android.net.LocalServerSocket
import android.os.Bundle
import android.util.Log
import android.view.inputmethod.EditorInfo
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.AppCompatEditText
import java.io.BufferedReader
import java.io.InputStreamReader
import java.lang.ref.WeakReference
import java.util.*
import java.util.concurrent.Semaphore
import kotlin.concurrent.thread
// ghc's rts
external fun initHS()
// android-support
external fun pipeStdOutToSocket(socketName: String) : Int
// simplex-chat
typealias Store = Long
typealias Controller = Long
external fun chatInit(filesDir: String): Store
external fun chatGetUser(controller: Store) : String
external fun chatCreateUser(controller: Store, data: String) : String
external fun chatStart(controller: Store) : Controller
external fun chatSendCmd(controller: Controller, msg: String) : String
external fun chatRecvMsg(controller: Controller) : String
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
weakActivity = WeakReference(this)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val store : Store = chatInit(this.applicationContext.filesDir.toString())
// create user if needed
if(chatGetUser(store) == "{}") {
chatCreateUser(store, """
{"displayName": "test", "fullName": "android test"}
""".trimIndent())
}
Log.d("SIMPLEX (user)", chatGetUser(store))
val controller = chatStart(store)
val cmdinput = this.findViewById<AppCompatEditText>(R.id.cmdInput)
cmdinput.setOnEditorActionListener { _, actionId, _ ->
when (actionId) {
EditorInfo.IME_ACTION_SEND -> {
Log.d("SIMPLEX SEND", chatSendCmd(controller, cmdinput.text.toString()))
cmdinput.text?.clear()
true
}
else -> false
}
}
thread(name="receiver") {
val chatlog = FifoQueue<String>(500)
while(true) {
val msg = chatRecvMsg(controller)
Log.d("SIMPLEX RECV", msg)
chatlog.add(msg)
val currentText = chatlog.joinToString("\n")
weakActivity.get()?.runOnUiThread {
val log = weakActivity.get()?.findViewById<TextView>(R.id.chatlog)
val scroll = weakActivity.get()?.findViewById<ScrollView>(R.id.scroller)
log?.text = currentText
scroll?.scrollTo(0, scroll.getChildAt(0).height)
}
}
}
}
companion object {
lateinit var weakActivity : WeakReference<MainActivity>
init {
val socketName = "local.socket.address.listen.native.cmd2"
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d("SIMPLEX", "starting server")
val server = LocalServerSocket(socketName)
Log.d("SIMPLEX", "started server")
s.release()
val receiver = server.accept()
Log.d("SIMPLEX", "started receiver")
val logbuffer = FifoQueue<String>(500)
if (receiver != null) {
val inStream = receiver.inputStream
val inStreamReader = InputStreamReader(inStream)
val input = BufferedReader(inStreamReader)
while(true) {
val line = input.readLine() ?: break
Log.d("SIMPLEX (stdout/stderr)", line)
logbuffer.add(line)
}
}
}
System.loadLibrary("app-lib")
s.acquire()
pipeStdOutToSocket(socketName)
initHS()
}
}
}
class FifoQueue<E>(private var capacity: Int) : LinkedList<E>() {
override fun add(element: E): Boolean {
if(size > capacity) removeFirst()
return super.add(element)
}
}
@@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<EditText
android:id="@+id/cmdInput"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:autofillHints=""
android:ems="10"
android:imeOptions="actionSend"
android:inputType="textPersonName"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:ignore="SpeakableTextPresentCheck" />
<ScrollView
android:id="@+id/scroller"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:layout_constraintBottom_toTopOf="@+id/cmdInput"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/chatlog"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</LinearLayout>
</ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,16 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.SimpleX" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_200</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/black</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_200</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -1,3 +0,0 @@
<resources>
<string name="app_name">SimpleX</string>
</resources>
@@ -1,16 +0,0 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.SimpleX" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>
-67
View File
@@ -1,67 +0,0 @@
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# Accio dependency management
Dependencies/
.accio/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Code Injection
#
# After new code Injection tools there's a generated folder /iOSInjectionProject
# https://github.com/johnno1962/injectionforxcode
iOSInjectionProject/
Libraries/
-44
View File
@@ -1,44 +0,0 @@
//
// ContentView.swift
// Shared
//
// Created by Evgeny Poberezkin on 17/01/2022.
//
import SwiftUI
struct ContentView: View {
@EnvironmentObject var chatModel: ChatModel
var body: some View {
if let user = chatModel.currentUser {
ChatListView(user: user)
.onAppear {
DispatchQueue.global().async {
while(true) {
do {
try processReceivedMsg(chatModel, chatRecvMsg())
} catch {
print("error receiving message: ", error)
}
}
}
do {
chatModel.chats = try apiGetChats()
} catch {
print(error)
}
}
} else {
WelcomeView()
}
}
}
//struct ContentView_Previews: PreviewProvider {
// static var previews: some View {
// ContentView(text: "Hello!")
// }
//}
-383
View File
@@ -1,383 +0,0 @@
//
// ChatModel.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 22/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import Combine
import SwiftUI
final class ChatModel: ObservableObject {
@Published var currentUser: User?
// list of chat "previews"
@Published var chats: [Chat] = []
// current chat
@Published var chatId: String?
@Published var chatItems: [ChatItem] = []
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: String?
@Published var appOpenUrl: URL?
@Published var connectViaUrl = false
func hasChat(_ id: String) -> Bool {
chats.first(where: { $0.id == id }) != nil
}
func getChat(_ id: String) -> Chat? {
chats.first(where: { $0.id == id })
}
func addChat(_ chat: Chat) {
withAnimation {
chats.insert(chat, at: 0)
}
}
func updateChatInfo(_ cInfo: ChatInfo) {
if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) {
chats[ix].chatInfo = cInfo
}
}
func replaceChat(_ id: String, _ chat: Chat) {
if let ix = chats.firstIndex(where: { $0.id == id }) {
chats[ix] = chat
} else {
// invalid state, correcting
chats.insert(chat, at: 0)
}
}
func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
if let ix = chats.firstIndex(where: { $0.id == cInfo.id }) {
chats[ix].chatItems = [cItem]
if chatId != cInfo.id {
let chat = chats.remove(at: ix)
chats.insert(chat, at: 0)
}
}
if chatId == cInfo.id {
chatItems.append(cItem)
}
}
func removeChat(_ id: String) {
withAnimation {
chats.removeAll(where: { $0.id == id })
}
}
}
struct User: Decodable {
var userId: Int64
var userContactId: Int64
var localDisplayName: ContactName
var profile: Profile
var activeUser: Bool
// internal init(userId: Int64, userContactId: Int64, localDisplayName: ContactName, profile: Profile, activeUser: Bool) {
// self.userId = userId
// self.userContactId = userContactId
// self.localDisplayName = localDisplayName
// self.profile = profile
// self.activeUser = activeUser
// }
}
let sampleUser = User(
userId: 1,
userContactId: 1,
localDisplayName: "alice",
profile: sampleProfile,
activeUser: true
)
typealias ContactName = String
typealias GroupName = String
struct Profile: Codable {
var displayName: String
var fullName: String
}
let sampleProfile = Profile(
displayName: "alice",
fullName: "Alice"
)
enum ChatType: String {
case direct = "@"
case group = "#"
case contactRequest = "<@"
}
enum ChatInfo: Identifiable, Decodable {
case direct(contact: Contact)
case group(groupInfo: GroupInfo)
case contactRequest(contactRequest: UserContactRequest)
var localDisplayName: String {
get {
switch self {
case let .direct(contact): return "@\(contact.localDisplayName)"
case let .group(groupInfo): return "#\(groupInfo.localDisplayName)"
case let .contactRequest(contactRequest): return "< @\(contactRequest.localDisplayName)"
}
}
}
var id: String {
get {
switch self {
case let .direct(contact): return contact.id
case let .group(groupInfo): return groupInfo.id
case let .contactRequest(contactRequest): return contactRequest.id
}
}
}
var chatType: ChatType {
get {
switch self {
case .direct: return .direct
case .group: return .group
case .contactRequest: return .contactRequest
}
}
}
var apiId: Int64 {
get {
switch self {
case let .direct(contact): return contact.apiId
case let .group(groupInfo): return groupInfo.apiId
case let .contactRequest(contactRequest): return contactRequest.apiId
}
}
}
}
let sampleDirectChatInfo = ChatInfo.direct(contact: sampleContact)
let sampleGroupChatInfo = ChatInfo.group(groupInfo: sampleGroupInfo)
let sampleContactRequestChatInfo = ChatInfo.contactRequest(contactRequest: sampleContactRequest)
final class Chat: ObservableObject, Identifiable {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
init(_ cData: ChatData) {
self.chatInfo = cData.chatInfo
self.chatItems = cData.chatItems
}
init(chatInfo: ChatInfo, chatItems: [ChatItem] = []) {
self.chatInfo = chatInfo
self.chatItems = chatItems
}
var id: String { get { chatInfo.id } }
}
struct ChatData: Decodable, Identifiable {
var chatInfo: ChatInfo
var chatItems: [ChatItem]
var id: String { get { chatInfo.id } }
}
struct Contact: Identifiable, Decodable {
var contactId: Int64
var localDisplayName: ContactName
var profile: Profile
var activeConn: Connection
var viaGroup: Int64?
var id: String { get { "@\(contactId)" } }
var apiId: Int64 { get { contactId } }
var connected: Bool { get { activeConn.connStatus == "ready" || activeConn.connStatus == "snd-ready" } }
}
let sampleContact = Contact(
contactId: 1,
localDisplayName: "alice",
profile: sampleProfile,
activeConn: sampleConnection
)
struct Connection: Decodable {
var connStatus: String
}
let sampleConnection = Connection(connStatus: "ready")
struct UserContactRequest: Decodable {
var contactRequestId: Int64
var localDisplayName: ContactName
var profile: Profile
var id: String { get { "<@\(contactRequestId)" } }
var apiId: Int64 { get { contactRequestId } }
}
let sampleContactRequest = UserContactRequest(
contactRequestId: 1,
localDisplayName: "alice",
profile: sampleProfile
)
struct GroupInfo: Identifiable, Decodable {
var groupId: Int64
var localDisplayName: GroupName
var groupProfile: GroupProfile
var id: String { get { "#\(groupId)" } }
var apiId: Int64 { get { groupId } }
}
let sampleGroupInfo = GroupInfo(
groupId: 1,
localDisplayName: "team",
groupProfile: sampleGroupProfile
)
struct GroupProfile: Codable {
var displayName: String
var fullName: String
}
let sampleGroupProfile = GroupProfile(
displayName: "team",
fullName: "My Team"
)
struct GroupMember: Decodable {
}
struct AChatItem: Decodable {
var chatInfo: ChatInfo
var chatItem: ChatItem
}
struct ChatItem: Identifiable, Decodable {
var chatDir: CIDirection
var meta: CIMeta
var content: CIContent
var id: Int64 { get { meta.itemId } }
}
func chatItemSample(_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String) -> ChatItem {
ChatItem(
chatDir: dir,
meta: ciMetaSample(id, ts, text),
content: .sndMsgContent(msgContent: .text(text))
)
}
enum CIDirection: Decodable {
case directSnd
case directRcv
case groupSnd
case groupRcv(GroupMember)
var sent: Bool {
get {
switch self {
case .directSnd: return true
case .directRcv: return false
case .groupSnd: return true
case .groupRcv: return false
}
}
}
}
struct CIMeta: Decodable {
var itemId: Int64
var itemTs: Date
var itemText: String
var createdAt: Date
}
func ciMetaSample(_ id: Int64, _ ts: Date, _ text: String) -> CIMeta {
CIMeta(
itemId: id,
itemTs: ts,
itemText: text,
createdAt: ts
)
}
enum CIContent: Decodable {
case sndMsgContent(msgContent: MsgContent)
case rcvMsgContent(msgContent: MsgContent)
// files etc.
var text: String {
get {
switch self {
case let .sndMsgContent(mc): return mc.string
case let .rcvMsgContent(mc): return mc.string
}
}
}
}
enum MsgContent {
case text(String)
case unknown(type: String, text: String)
case invalid(error: String)
var string: String {
get {
switch self {
case let .text(text): return text
case let .unknown(_, text): return text
case .invalid: return "invalid"
}
}
}
var cmdString: String {
get {
switch self {
case let .text(text): return "text \(text)"
default: return ""
}
}
}
enum CodingKeys: String, CodingKey {
case type
case text
}
}
extension MsgContent: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
let type = try container.decode(String.self, forKey: CodingKeys.type)
switch type {
case "text":
let text = try container.decode(String.self, forKey: CodingKeys.text)
self = .text(text)
default:
let text = try? container.decode(String.self, forKey: CodingKeys.text)
self = .unknown(type: type, text: text ?? "unknown message format")
}
} catch {
self = .invalid(error: String(describing: error))
}
}
}
-38
View File
@@ -1,38 +0,0 @@
//
// JSON.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
func getJSONDecoder() -> JSONDecoder {
let jd = JSONDecoder()
let fracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ")
let noFracSeconds = getDateFormatter("yyyy-MM-dd'T'HH:mm:ssZZZZZ")
jd.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = fracSeconds.date(from: string) ?? noFracSeconds.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
}
return jd
}
func getJSONEncoder() -> JSONEncoder {
let je = JSONEncoder()
je.dateEncodingStrategy = .iso8601
return je
}
private func getDateFormatter(_ format: String) -> DateFormatter {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.dateFormat = format
df.timeZone = TimeZone(secondsFromGMT: 0)
return df
}
-409
View File
@@ -1,409 +0,0 @@
//
// ChatAPI.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 27/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import Foundation
import UIKit
private var chatStore: chat_store?
private var chatController: chat_ctrl?
private let jsonDecoder = getJSONDecoder()
private let jsonEncoder = getJSONEncoder()
enum ChatCommand {
case apiGetChats
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
case apiUpdateProfile(profile: Profile)
case createMyAddress
case deleteMyAddress
case showMyAddress
case apiAcceptContact(contactReqId: Int64)
case apiRejectContact(contactReqId: Int64)
case string(String)
var cmdString: String {
get {
switch self {
case .apiGetChats:
return "/_get chats"
case let .apiGetChat(type, id):
return "/_get chat \(type.rawValue)\(id) count=500"
case let .apiSendMessage(type, id, mc):
return "/_send \(type.rawValue)\(id) \(mc.cmdString)"
case .addContact:
return "/connect"
case let .connect(connReq):
return "/connect \(connReq)"
case let .apiDeleteChat(type, id):
return "/_delete \(type.rawValue)\(id)"
case let .apiUpdateProfile(profile):
return "/profile \(profile.displayName) \(profile.fullName)"
case .createMyAddress:
return "/address"
case .deleteMyAddress:
return "/delete_address"
case .showMyAddress:
return "/show_address"
case let .apiAcceptContact(contactReqId):
return "/_accept \(contactReqId)"
case let .apiRejectContact(contactReqId):
return "/_reject \(contactReqId)"
case let .string(str):
return str
}
}
}
}
struct APIResponse: Decodable {
var resp: ChatResponse
}
enum ChatResponse: Decodable, Error {
case response(type: String, json: String)
case apiChats(chats: [ChatData])
case apiChat(chat: ChatData)
case invitation(connReqInvitation: String)
case sentConfirmation
case sentInvitation
case contactDeleted(contact: Contact)
case userProfileNoChange
case userProfileUpdated(fromProfile: Profile, toProfile: Profile)
case userContactLink(connReqContact: String)
case userContactLinkCreated(connReqContact: String)
case userContactLinkDeleted
case contactConnected(contact: Contact)
case receivedContactRequest(contactRequest: UserContactRequest)
case acceptingContactRequest(contact: Contact)
case contactRequestRejected
case newChatItem(chatItem: AChatItem)
case chatCmdError(chatError: ChatError)
var responseType: String {
get {
switch self {
case let .response(type, _): return "* \(type)"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .invitation: return "invitation"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
case .contactDeleted: return "contactDeleted"
case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileNoChange"
case .userContactLink: return "userContactLink"
case .userContactLinkCreated: return "userContactLinkCreated"
case .userContactLinkDeleted: return "userContactLinkDeleted"
case .contactConnected: return "contactConnected"
case .receivedContactRequest: return "receivedContactRequest"
case .acceptingContactRequest: return "acceptingContactRequest"
case .contactRequestRejected: return "contactRequestRejected"
case .newChatItem: return "newChatItem"
case .chatCmdError: return "chatCmdError"
}
}
}
var details: String {
get {
switch self {
case let .response(_, json): return json
case let .apiChats(chats): return String(describing: chats)
case let .apiChat(chat): return String(describing: chat)
case let .invitation(connReqInvitation): return connReqInvitation
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
case let .contactDeleted(contact): return String(describing: contact)
case .userProfileNoChange: return noDetails
case let .userProfileUpdated(_, toProfile): return String(describing: toProfile)
case let .userContactLink(connReq): return connReq
case let .userContactLinkCreated(connReq): return connReq
case .userContactLinkDeleted: return noDetails
case let .contactConnected(contact): return String(describing: contact)
case let .receivedContactRequest(contactRequest): return String(describing: contactRequest)
case let .acceptingContactRequest(contact): return String(describing: contact)
case .contactRequestRejected: return noDetails
case let .newChatItem(chatItem): return String(describing: chatItem)
case let .chatCmdError(chatError): return String(describing: chatError)
}
}
}
private var noDetails: String { get { "\(responseType): no details" } }
}
enum TerminalItem: Identifiable {
case cmd(Date, ChatCommand)
case resp(Date, ChatResponse)
var id: Date {
get {
switch self {
case let .cmd(id, _): return id
case let .resp(id, _): return id
}
}
}
var label: String {
get {
switch self {
case let .cmd(_, cmd): return "> \(cmd.cmdString.prefix(30))"
case let .resp(_, resp): return "< \(resp.responseType)"
}
}
}
var details: String {
get {
switch self {
case let .cmd(_, cmd): return cmd.cmdString
case let .resp(_, resp): return resp.details
}
}
}
}
func chatGetUser() -> User? {
let store = getStore()
print("chatGetUser")
let r: UserResponse? = decodeCJSON(chat_get_user(store))
let user = r?.user
if user != nil { initChatCtrl(store) }
print("user", user as Any)
return user
}
func chatCreateUser(_ p: Profile) -> User? {
let store = getStore()
print("chatCreateUser")
var str = encodeCJSON(p)
chat_create_user(store, &str)
let user = chatGetUser()
if user != nil { initChatCtrl(store) }
print("user", user as Any)
return user
}
func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
var c = cmd.cmdString.cString(using: .utf8)!
print("command", cmd.cmdString)
// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber?
// DispatchQueue.main.async {
// termId += 1
// chatModel.terminalItems.append(.cmd(termId, cmd))
// }
return chatResponse(chat_send_cmd(getChatCtrl(), &c)!)
}
func chatRecvMsg() throws -> ChatResponse {
chatResponse(chat_recv_msg(getChatCtrl())!)
}
func apiGetChats() throws -> [Chat] {
let r = try chatSendCmd(.apiGetChats)
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
throw r
}
func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
let r = try chatSendCmd(.apiGetChat(type: type, id: id))
if case let .apiChat(chat) = r { return Chat.init(chat) }
throw r
}
func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) throws -> ChatItem {
let r = try chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
if case let .newChatItem(aChatItem) = r { return aChatItem.chatItem }
throw r
}
func apiAddContact() throws -> String {
let r = try chatSendCmd(.addContact)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
throw r
}
func apiConnect(connReq: String) throws {
let r = try chatSendCmd(.connect(connReq: connReq))
switch r {
case .sentConfirmation: return
case .sentInvitation: return
default: throw r
}
}
func apiDeleteChat(type: ChatType, id: Int64) throws {
let r = try chatSendCmd(.apiDeleteChat(type: type, id: id))
if case .contactDeleted = r { return }
throw r
}
func apiUpdateProfile(profile: Profile) throws -> Profile? {
let r = try chatSendCmd(.apiUpdateProfile(profile: profile))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(_, toProfile): return toProfile
default: throw r
}
}
func apiCreateUserAddress() throws -> String {
let r = try chatSendCmd(.createMyAddress)
if case let .userContactLinkCreated(connReq) = r { return connReq }
throw r
}
func apiDeleteUserAddress() throws {
let r = try chatSendCmd(.deleteMyAddress)
if case .userContactLinkDeleted = r { return }
throw r
}
func apiGetUserAddress() throws -> String? {
let r = try chatSendCmd(.showMyAddress)
switch r {
case let .userContactLink(connReq):
return connReq
case .chatCmdError(chatError: .errorStore(storeError: .userContactLinkNotFound)):
return nil
default: throw r
}
}
func apiAcceptContactRequest(contactReqId: Int64) throws -> Contact {
let r = try chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
if case let .acceptingContactRequest(contact) = r { return contact }
throw r
}
func apiRejectContactRequest(contactReqId: Int64) throws {
let r = try chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
if case .contactRequestRejected = r { return }
throw r
}
func processReceivedMsg(_ chatModel: ChatModel, _ res: ChatResponse) {
DispatchQueue.main.async {
chatModel.terminalItems.append(.resp(Date.now, res))
switch res {
case let .contactConnected(contact):
let cInfo = ChatInfo.direct(contact: contact)
if chatModel.hasChat(contact.id) {
chatModel.updateChatInfo(cInfo)
} else {
chatModel.addChat(Chat(chatInfo: cInfo, chatItems: []))
}
case let .receivedContactRequest(contactRequest):
chatModel.addChat(Chat(
chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
chatItems: []
))
case let .newChatItem(aChatItem):
chatModel.addChatItem(aChatItem.chatInfo, aChatItem.chatItem)
default:
print("unsupported response: ", res.responseType)
}
}
}
private struct UserResponse: Decodable {
var user: User?
var error: String?
}
private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
// TODO is there a way to do it without copying the data? e.g:
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
// TODO some mechanism to update model without passing it - maybe Publisher / Subscriber?
do {
let r = try jsonDecoder.decode(APIResponse.self, from: d)
return r.resp
} catch {
print (error)
}
var type: String?
var json: String?
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
if let j1 = j["resp"] as? NSDictionary, j1.count == 1 {
type = j1.allKeys[0] as? String
}
json = prettyJSON(j)
}
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
}
func prettyJSON(_ obj: NSDictionary) -> String? {
if let d = try? JSONSerialization.data(withJSONObject: obj, options: .prettyPrinted) {
return String(decoding: d, as: UTF8.self)
}
return nil
}
private func getStore() -> chat_store {
if let store = chatStore { return store }
let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1"
var cstr = dataDir.cString(using: .utf8)!
chatStore = chat_init_store(&cstr)
return chatStore!
}
private func initChatCtrl(_ store: chat_store) {
if chatController == nil {
chatController = chat_start(store)
}
}
private func getChatCtrl() -> chat_ctrl {
if let controller = chatController { return controller }
fatalError("Chat controller was not started!")
}
private func decodeCJSON<T: Decodable>(_ cjson: UnsafePointer<CChar>) -> T? {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
// let p = UnsafeMutableRawPointer.init(mutating: UnsafeRawPointer(cjson))
// let d = Data.init(bytesNoCopy: p, count: strlen(cjson), deallocator: .free)
return try? jsonDecoder.decode(T.self, from: d)
}
private func getJSONObject(_ cjson: UnsafePointer<CChar>) -> NSDictionary? {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
return try? JSONSerialization.jsonObject(with: d) as? NSDictionary
}
private func encodeCJSON<T: Encodable>(_ value: T) -> [CChar] {
let data = try! jsonEncoder.encode(value)
let str = String(decoding: data, as: UTF8.self)
return str.cString(using: .utf8)!
}
enum ChatError: Decodable {
case error(errorType: ChatErrorType)
case errorStore(storeError: StoreError)
// TODO other error cases
}
enum ChatErrorType: Decodable {
case invalidConnReq
}
enum StoreError: Decodable {
case userContactLinkNotFound
// TODO other error cases
}
@@ -1,14 +0,0 @@
import Foundation
var greeting = "Hello, playground"
let jsonEncoder = JSONEncoder()
//jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!)
var a = [1, 2, 3]
a.removeAll(where: { $0 == 1} )
print(a)
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
<timeline fileName='timeline.xctimeline'/>
</playground>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Timeline
version = "3.0">
<TimelineItems>
<LoggerValueHistoryTimelineItem
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&amp;CharacterRangeLoc=91&amp;EndingColumnNumber=0&amp;EndingLineNumber=7&amp;StartingColumnNumber=3&amp;StartingLineNumber=6&amp;Timestamp=665423482.97412"
selectedRepresentationIndex = "0"
shouldTrackSuperviewWidth = "NO">
</LoggerValueHistoryTimelineItem>
</TimelineItems>
</Timeline>
@@ -1,15 +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_store;
typedef void* chat_ctrl;
extern chat_store chat_init_store(char *path);
extern char *chat_get_user(chat_store store);
extern char *chat_create_user(chat_store store, char *data);
extern chat_ctrl chat_start(chat_store store);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);
@@ -1,15 +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_store;
typedef void* chat_ctrl;
extern chat_store chat_init_store(char * path);
extern char *chat_get_user(chat_store store);
extern char *chat_create_user(chat_store store, char *data);
extern chat_ctrl chat_start(chat_store store);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl);
-32
View File
@@ -1,32 +0,0 @@
//
// SimpleXApp.swift
// Shared
//
// Created by Evgeny Poberezkin on 17/01/2022.
//
import SwiftUI
@main
struct SimpleXApp: App {
@StateObject private var chatModel = ChatModel()
init() {
hs_init(0, nil)
}
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(chatModel)
.onOpenURL { url in
chatModel.appOpenUrl = url
chatModel.connectViaUrl = true
print(url)
}
.onAppear() {
chatModel.currentUser = chatGetUser()
}
}
}
}
@@ -1,64 +0,0 @@
//
// ChatItemView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 30/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private var dateFormatter: DateFormatter?
struct ChatItemView: View {
var chatItem: ChatItem
var body: some View {
let sent = chatItem.chatDir.sent
return VStack {
Group {
Text(chatItem.content.text)
.padding(.top, 8)
.padding(.horizontal, 12)
.frame(minWidth: 200, maxWidth: 300, alignment: .leading)
.foregroundColor(sent ? .white : .primary)
.textSelection(.enabled)
Text(getDateFormatter().string(from: chatItem.meta.itemTs))
.font(.subheadline)
.foregroundColor(sent ? .white : .secondary)
.padding(.bottom, 8)
.padding(.horizontal, 12)
.frame(minWidth: 200, maxWidth: 300, alignment: .trailing)
}
}
.background(sent ? .blue : Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(10)
.padding(.horizontal)
.frame(
minWidth: 200,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: sent ? .trailing : .leading
)
}
}
func getDateFormatter() -> DateFormatter {
if let df = dateFormatter { return df }
let df = DateFormatter()
df.dateFormat = "HH:mm"
dateFormatter = df
return df
}
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chatItem: chatItemSample(1, .directSnd, Date.now, "hello"))
ChatItemView(chatItem: chatItemSample(2, .directRcv, Date.now, "hello there too"))
}
.previewLayout(.fixed(width: 300, height: 70))
}
}
-80
View File
@@ -1,80 +0,0 @@
//
// ChatView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 27/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
var chatInfo: ChatInfo
@State private var inProgress: Bool = false
var body: some View {
VStack {
ScrollViewReader { proxy in
ScrollView {
VStack {
ForEach(chatModel.chatItems, id: \.id) {
ChatItemView(chatItem: $0)
}
.onAppear { scrollToBottom(proxy) }
.onChange(of: chatModel.chatItems.count) { _ in scrollToBottom(proxy) }
}
}
}
Spacer(minLength: 0)
SendMessageView(sendMessage: sendMessage, inProgress: inProgress)
}
.navigationTitle(chatInfo.localDisplayName)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { chatModel.chatId = nil } label: {
Image(systemName: "chevron.backward")
}
}
}
.navigationBarBackButtonHidden(true)
}
func scrollToBottom(_ proxy: ScrollViewProxy) {
if let id = chatModel.chatItems.last?.id {
withAnimation {
proxy.scrollTo(id, anchor: .bottom)
}
}
}
func sendMessage(_ msg: String) {
do {
let chatItem = try apiSendMessage(type: chatInfo.chatType, id: chatInfo.apiId, msg: .text(msg))
chatModel.addChatItem(chatInfo, chatItem)
} catch {
print(error)
}
}
}
struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
chatModel.chatItems = [
chatItemSample(1, .directSnd, Date.now, "hello"),
chatItemSample(2, .directRcv, Date.now, "hi"),
chatItemSample(3, .directRcv, Date.now, "hi there"),
chatItemSample(4, .directRcv, Date.now, "hello again"),
chatItemSample(5, .directSnd, Date.now, "hi there!!!"),
chatItemSample(6, .directSnd, Date.now, "how are you?"),
chatItemSample(7, .directSnd, Date.now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")
]
return ChatView(chatInfo: sampleDirectChatInfo)
.environmentObject(chatModel)
}
}
@@ -1,46 +0,0 @@
//
// SendMessageView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct SendMessageView: View {
var sendMessage: (String) -> Void
var inProgress: Bool = false
@State var command: String = ""
var body: some View {
HStack {
TextField("Message...", text: $command)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.onSubmit(submit)
if (inProgress) {
ProgressView()
.frame(width: 40, height: 20, alignment: .center)
} else {
Button("Send", action :submit)
.disabled(command.isEmpty)
}
}
.frame(minHeight: 30)
.padding(12)
}
func submit() {
sendMessage(command)
command = ""
}
}
struct SendMessageView_Previews: PreviewProvider {
static var previews: some View {
SendMessageView(sendMessage: { print ($0) })
}
}
@@ -1,192 +0,0 @@
//
// ChatListNavLink.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 01/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@State var chat: Chat
@State private var showDeleteContactAlert = false
@State private var showDeleteGroupAlert = false
@State private var showContactRequestAlert = false
@State private var showContactRequestDialog = false
@State private var alertContact: Contact?
@State private var alertGroupInfo: GroupInfo?
@State private var alertContactRequest: UserContactRequest?
var body: some View {
switch chat.chatInfo {
case let .direct(contact):
contactNavLink(contact)
case let .group(groupInfo):
groupNavLink(groupInfo)
case let .contactRequest(cReq):
contactRequestNavLink(cReq)
}
}
private func chatView() -> some View {
ChatView(chatInfo: chat.chatInfo)
.onAppear {
do {
let cInfo = chat.chatInfo
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId)
chatModel.updateChatInfo(chat.chatInfo)
chatModel.chatItems = chat.chatItems
} catch {
print("apiGetChatItems", error)
}
}
}
private func contactNavLink(_ contact: Contact) -> some View {
NavigationLink(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
destination: { chatView() },
label: { ChatPreviewView(chat: chat) }
)
.disabled(!contact.connected)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
alertContact = contact
showDeleteContactAlert = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.alert(isPresented: $showDeleteContactAlert) {
deleteContactAlert(alertContact!)
}
.frame(height: 80)
}
private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
NavigationLink(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
destination: { chatView() },
label: { ChatPreviewView(chat: chat) }
)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
alertGroupInfo = groupInfo
showDeleteGroupAlert = true
} label: {
Label("Delete", systemImage: "trash")
}
}
.alert(isPresented: $showDeleteGroupAlert) {
deleteGroupAlert(alertGroupInfo!)
}
.frame(height: 80)
}
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { acceptContactRequest(contactRequest) }
label: { Label("Accept", systemImage: "checkmark") }
.tint(.blue)
Button(role: .destructive) {
alertContactRequest = contactRequest
showContactRequestAlert = true
} label: {
Label("Reject", systemImage: "multiply")
}
}
.alert(isPresented: $showContactRequestAlert) {
contactRequestAlert(alertContactRequest!)
}
.frame(height: 80)
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Connection request", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept contact") { acceptContactRequest(contactRequest) }
Button("Reject contact (sender NOT notified)") { rejectContactRequest(contactRequest) }
}
}
private func deleteContactAlert(_ contact: Contact) -> Alert {
Alert(
title: Text("Delete contact?"),
message: Text("Contact and all messages will be deleted"),
primaryButton: .destructive(Text("Delete")) {
do {
try apiDeleteChat(type: .direct, id: contact.apiId)
chatModel.removeChat(contact.id)
} catch let error {
print("Error: \(error)")
}
alertContact = nil
}, secondaryButton: .cancel() {
alertContact = nil
}
)
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert(
title: Text("Delete group"),
message: Text("Group deletion is not supported")
)
}
private func contactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert(
title: Text("Reject contact request"),
message: Text("The sender will NOT be notified"),
primaryButton: .destructive(Text("Reject")) {
rejectContactRequest(contactRequest)
alertContactRequest = nil
}, secondaryButton: .cancel {
alertContactRequest = nil
}
)
}
private func acceptContactRequest(_ contactRequest: UserContactRequest) {
do {
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
chatModel.replaceChat(contactRequest.id, chat)
} catch let error {
print("Error: \(error)")
}
}
private func rejectContactRequest(_ contactRequest: UserContactRequest) {
do {
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
chatModel.removeChat(contactRequest.id)
} catch let error {
print("Error: \(error)")
}
}
}
struct ChatListNavLink_Previews: PreviewProvider {
static var previews: some View {
@State var chatId: String? = "@1"
return Group {
ChatListNavLink(chat: Chat(
chatInfo: sampleDirectChatInfo,
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
))
ChatListNavLink(chat: Chat(
chatInfo: sampleDirectChatInfo,
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
))
ChatListNavLink(chat: Chat(
chatInfo: sampleContactRequestChatInfo,
chatItems: []
))
}
.previewLayout(.fixed(width: 360, height: 80))
}
}
@@ -1,116 +0,0 @@
//
// ChatListView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 27/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ChatListView: View {
@EnvironmentObject var chatModel: ChatModel
@State private var connectAlert = false
@State private var connectError: Error?
var user: User
var body: some View {
VStack {
// if chatModel.chats.isEmpty {
// VStack {
// Text("Hello chat")
// Text("Active user: \(user.localDisplayName) (\(user.profile.fullName))")
// }
// }
NavigationView {
List {
NavigationLink {
TerminalView()
} label: {
Text("Terminal")
}
ForEach(chatModel.chats) { chat in
ChatListNavLink(chat: chat)
}
}
.padding(0)
.offset(x: -8)
.listStyle(.plain)
.navigationTitle("Your chats")
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
SettingsButton()
}
ToolbarItem(placement: .navigationBarTrailing) {
NewChatButton()
}
}
.alert(isPresented: $connectAlert) { connectionErrorAlert() }
}
.alert(isPresented: $chatModel.connectViaUrl) { connectViaUrlAlert() }
}
}
private func connectViaUrlAlert() -> Alert {
if let url = chatModel.appOpenUrl {
var path = url.path
if (path == "/contact" || path == "/invitation") {
path.removeFirst()
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
return Alert(
title: Text("Connect via \(path) link?"),
message: Text("Your profile will be sent to the contact that you received this link from: \(link)"),
primaryButton: .default(Text("Connect")) {
do {
try apiConnect(connReq: link)
} catch {
connectAlert = true
connectError = error
print(error)
}
chatModel.appOpenUrl = nil
}, secondaryButton: .cancel() {
chatModel.appOpenUrl = nil
}
)
} else {
return Alert(title: Text("Error: URL not available"))
}
} else {
return Alert(title: Text("Error: URL not available"))
}
}
private func connectionErrorAlert() -> Alert {
Alert(
title: Text("Connection error"),
message: Text(connectError?.localizedDescription ?? "")
)
}
}
struct ChatListView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chats = [
Chat(
chatInfo: sampleDirectChatInfo,
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
),
Chat(
chatInfo: sampleGroupChatInfo,
chatItems: [chatItemSample(1, .directSnd, Date.now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")]
),
Chat(
chatInfo: sampleContactRequestChatInfo,
chatItems: []
)
]
return ChatListView(user: sampleUser)
.environmentObject(chatModel)
}
}
@@ -1,70 +0,0 @@
//
// ChatNavLabel.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 28/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ChatPreviewView: View {
@ObservedObject var chat: Chat
var body: some View {
let cItem = chat.chatItems.last
return VStack(spacing: 4) {
HStack(alignment: .top) {
Text(chat.chatInfo.localDisplayName)
.font(.title3)
.fontWeight(.bold)
.padding(.leading, 8)
.padding(.top, 4)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
if let cItem = cItem {
Text(getDateFormatter().string(from: cItem.meta.itemTs))
.font(.subheadline)
.padding(.trailing, 8)
.padding(.top, 4)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
}
}
if let cItem = cItem {
Text(cItem.content.text)
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8)
.padding(.bottom, 4)
.padding(.top, 1)
}
// else if case let .direct(contact) = chatPreview.chatInfo, !contact.connected {
// Text("Connecting...")
// .frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
// .padding([.leading, .trailing], 8)
// .padding(.bottom, 4)
// .padding(.top, 1)
// }
}
}
}
struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatPreviewView(chat: Chat(
chatInfo: sampleDirectChatInfo,
chatItems: []
))
ChatPreviewView(chat: Chat(
chatInfo: sampleDirectChatInfo,
chatItems: [chatItemSample(1, .directSnd, Date.now, "hello")]
))
ChatPreviewView(chat: Chat(
chatInfo: sampleGroupChatInfo,
chatItems: [chatItemSample(1, .directSnd, Date.now, "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")]
))
}
.previewLayout(.fixed(width: 360, height: 80))
}
}
@@ -1,46 +0,0 @@
//
// ContactRequestView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 02/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ContactRequestView: View {
var contactRequest: UserContactRequest
var body: some View {
return VStack(alignment: .leading, spacing: 4) {
HStack(alignment: .top) {
Text("@\(contactRequest.localDisplayName)")
.font(.title3)
.fontWeight(.bold)
.foregroundColor(.blue)
.padding(.leading, 8)
.padding(.top, 4)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
Text("12:34")// getDateFormatter().string(from: cItem.meta.itemTs))
.font(.subheadline)
.padding(.trailing, 28)
.padding(.top, 4)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
}
Text("wants to connect to you!")
.frame(minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8)
.padding(.bottom, 4)
.padding(.top, 1)
}
}
}
struct ContactRequestView_Previews: PreviewProvider {
static var previews: some View {
ContactRequestView(contactRequest: sampleContactRequest)
.previewLayout(.fixed(width: 360, height: 80))
}
}
@@ -1,43 +0,0 @@
//
// AddContactView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import CoreImage.CIFilterBuiltins
struct AddContactView: View {
var connReqInvitation: String
@State private var shareInvitation = false
var body: some View {
VStack {
Text("Add contact")
.font(.title)
.padding(.bottom)
Text("Show QR code to your contact\nto scan from the app")
.font(.title2)
.multilineTextAlignment(.center)
QRCode(uri: connReqInvitation)
.padding()
Text("If you can't show QR code, you can share the invitation link via any channel")
.font(.subheadline)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button { shareInvitation = true } label: {
Label("Share", systemImage: "square.and.arrow.up")
}
.padding()
.shareSheet(isPresented: $shareInvitation, items: [connReqInvitation])
}
}
}
struct AddContactView_Previews: PreviewProvider {
static var previews: some View {
AddContactView(connReqInvitation: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D")
}
}
@@ -1,54 +0,0 @@
//
// ConnectContactView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import CodeScanner
struct ConnectContactView: View {
var completed: ((Error?) -> Void)
var body: some View {
VStack {
Text("Scan QR code")
.font(.title)
.padding(.bottom)
Text("Your chat profile will be sent to your contact.")
.font(.title2)
.multilineTextAlignment(.center)
.padding()
ZStack {
CodeScannerView(codeTypes: [.qr], completion: processQRCode)
.aspectRatio(1, contentMode: .fit)
.border(.gray)
}
.padding(13.0)
}
}
func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp {
case let .success(r):
do {
try apiConnect(connReq: r.string)
completed(nil)
} catch {
print(error)
completed(error)
}
case let .failure(e):
print(e)
completed(e)
}
}
}
struct ConnectContactView_Previews: PreviewProvider {
static var previews: some View {
return ConnectContactView(completed: {_ in })
}
}
@@ -1,21 +0,0 @@
//
// CreateGroupView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 29/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct CreateGroupView: View {
var body: some View {
Text("CreateGroupView")
}
}
struct CreateGroupView_Previews: PreviewProvider {
static var previews: some View {
CreateGroupView()
}
}
@@ -1,80 +0,0 @@
//
// NewChatButton.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 31/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct NewChatButton: View {
@State private var showAddChat = false
@State private var addContact = false
@State private var addContactAlert = false
@State private var addContactError: Error?
@State private var connReqInvitation: String = ""
@State private var connectContact = false
@State private var connectAlert = false
@State private var connectError: Error?
@State private var createGroup = false
var body: some View {
Button { showAddChat = true } label: {
Image(systemName: "square.and.pencil")
}
.confirmationDialog("Start new chat", isPresented: $showAddChat, titleVisibility: .visible) {
Button("Add contact") { addContactAction() }
Button("Scan QR code") { connectContact = true }
Button("Create group") { createGroup = true }
.disabled(true)
}
.sheet(isPresented: $addContact, content: {
AddContactView(connReqInvitation: connReqInvitation)
})
.alert(isPresented: $addContactAlert) {
connectionError(addContactError)
}
.sheet(isPresented: $connectContact, content: {
connectContactSheet()
})
.alert(isPresented: $connectAlert) {
connectionError(connectError)
}
.sheet(isPresented: $createGroup, content: { CreateGroupView() })
}
func addContactAction() {
do {
connReqInvitation = try apiAddContact()
addContact = true
} catch {
addContactAlert = true
addContactError = error
print(error)
}
}
func connectContactSheet() -> some View {
ConnectContactView(completed: { err in
connectContact = false
if err != nil {
connectAlert = true
connectError = err
}
})
}
func connectionError(_ error: Error?) -> Alert {
Alert(
title: Text("Connection error"),
message: Text(error?.localizedDescription ?? "")
)
}
}
struct NewChatButton_Previews: PreviewProvider {
static var previews: some View {
NewChatButton()
}
}
@@ -1,51 +0,0 @@
//
// QRCode.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 30/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
import CoreImage.CIFilterBuiltins
struct QRCode: View {
let uri: String
@State private var image: UIImage?
var body: some View {
ZStack {
if let image = image {
Image(uiImage: image)
.resizable()
.interpolation(.none)
.aspectRatio(1, contentMode: .fit)
.textSelection(.enabled)
}
}
.onAppear {
generateImage()
}
}
private func generateImage() {
guard image == nil else { return }
let context = CIContext()
let filter = CIFilter.qrCodeGenerator()
filter.message = Data(uri.utf8)
guard
let outputImage = filter.outputImage,
let cgImage = context.createCGImage(outputImage, from: outputImage.extent)
else { return }
self.image = UIImage(cgImage: cgImage)
}
}
struct QRCode_Previews: PreviewProvider {
static var previews: some View {
QRCode(uri: "https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FFe5ICmvrm4wkrr6X1LTMii-lhBqLeB76%23MCowBQYDK2VuAyEAdhZZsHpuaAk3Hh1q0uNb_6hGTpuwBIrsp2z9U2T0oC0%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAcz6jJk71InuxA0bOX7OUhddfB8Ov7xwQIlIDeXBRZaOntUU4brU5Y3rBzroZBdQJi0FKdtt_D7I%3D%2CMEIwBQYDK2VvAzkA-hDvk1duBi1hlOr08VWSI-Ou4JNNSQjseY69QyKm7Kgg1zZjbpGfyBqSZ2eqys6xtoV4ZtoQUXQ%3D")
}
}
@@ -1,40 +0,0 @@
//
// ShareSheet.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 30/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
extension UIApplication {
static let keyWindow = keyWindowScene?.windows.filter(\.isKeyWindow).first
static let keyWindowScene = shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
}
extension View {
func shareSheet(isPresented: Binding<Bool>, items: [Any]) -> some View {
guard isPresented.wrappedValue else { return self }
let activityViewController = UIActivityViewController(activityItems: items, applicationActivities: nil)
let presentedViewController = UIApplication.keyWindow?.rootViewController?.presentedViewController ?? UIApplication.keyWindow?.rootViewController
activityViewController.completionWithItemsHandler = { _, _, _, _ in isPresented.wrappedValue = false }
presentedViewController?.present(activityViewController, animated: true)
return self
}
}
struct ShareSheetTest: View {
@State private var isPresentingShareSheet = false
var body: some View {
Button("Show Share Sheet") { isPresentingShareSheet = true }
.shareSheet(isPresented: $isPresentingShareSheet, items: ["Share me!"])
}
}
struct ShareSheetTest_Previews: PreviewProvider {
static var previews: some View {
ShareSheetTest()
}
}
-72
View File
@@ -1,72 +0,0 @@
//
// TerminalView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 27/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct TerminalView: View {
@EnvironmentObject var chatModel: ChatModel
@State var inProgress: Bool = false
var body: some View {
VStack {
ScrollView {
LazyVStack {
ForEach(chatModel.terminalItems) { item in
NavigationLink {
ScrollView {
Text(item.details)
.textSelection(.enabled)
}
} label: {
Text(item.label)
.frame(width: 360, height: 30, alignment: .leading)
}
}
}
}
.navigationViewStyle(.stack)
Spacer()
SendMessageView(sendMessage: sendMessage, inProgress: inProgress)
}
}
func sendMessage(_ cmdStr: String) {
let cmd = ChatCommand.string(cmdStr)
chatModel.terminalItems.append(.cmd(Date.now, cmd))
DispatchQueue.global().async {
inProgress = true
do {
let r = try chatSendCmd(cmd)
DispatchQueue.main.async {
chatModel.terminalItems.append(.resp(Date.now, r))
}
} catch {
print(error)
}
inProgress = false
}
}
}
struct TerminalView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.terminalItems = [
.resp(Date.now, ChatResponse.response(type: "contactSubscribed", json: "{}")),
.resp(Date.now, ChatResponse.response(type: "newChatItem", json: "{}"))
]
return NavigationView {
TerminalView()
.environmentObject(chatModel)
}
}
}
@@ -1,36 +0,0 @@
//
// SettingsButton.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 31/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct SettingsButton: View {
@EnvironmentObject var chatModel: ChatModel
@State private var showSettings = false
var body: some View {
Button { showSettings = true } label: {
Image(systemName: "gearshape")
}
.sheet(isPresented: $showSettings, content: {
SettingsView()
.onAppear {
do {
chatModel.userAddress = try apiGetUserAddress()
} catch {
print(error)
}
}
})
}
}
struct SettingsButton_Previews: PreviewProvider {
static var previews: some View {
SettingsButton()
}
}
@@ -1,27 +0,0 @@
//
// SettingsView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 31/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var chatModel: ChatModel
var body: some View {
UserProfile()
UserAddress()
}
}
struct SettingsView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = sampleUser
return SettingsView()
.environmentObject(chatModel)
}
}
@@ -1,75 +0,0 @@
//
// UserAddress.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 31/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct UserAddress: View {
@EnvironmentObject var chatModel: ChatModel
@State private var shareAddressLink = false
@State private var deleteAddressAlert = false
var body: some View {
VStack (alignment: .leading) {
Text("Your chat address")
.font(.title)
.padding(.bottom)
Text("Your 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.")
.padding(.bottom)
if let userAdress = chatModel.userAddress {
QRCode(uri: userAdress)
HStack {
Button { shareAddressLink = true } label: {
Label("Share link", systemImage: "square.and.arrow.up")
}
.padding()
.shareSheet(isPresented: $shareAddressLink, items: [userAdress])
Button { deleteAddressAlert = true } label: {
Label("Delete address", systemImage: "trash")
}
.padding()
.alert(isPresented: $deleteAddressAlert) {
Alert(
title: Text("Delete address?"),
message: Text("All your contacts will remain connected"),
primaryButton: .destructive(Text("Delete")) {
do {
try apiDeleteUserAddress()
chatModel.userAddress = nil
} catch let error {
print("Error: \(error)")
}
}, secondaryButton: .cancel()
)
}
.shareSheet(isPresented: $shareAddressLink, items: [userAdress])
}
.frame(maxWidth: .infinity)
} else {
Button {
do {
chatModel.userAddress = try apiCreateUserAddress()
} catch let error {
print("Error: \(error)")
}
} label: { Label("Create address", systemImage: "qrcode") }
.frame(maxWidth: .infinity)
}
}
.padding()
}
}
struct UserAddress_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D"
return UserAddress()
.environmentObject(chatModel)
}
}
@@ -1,86 +0,0 @@
//
// UserProfile.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 31/01/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct UserProfile: View {
@EnvironmentObject var chatModel: ChatModel
@State private var profile = Profile(displayName: "", fullName: "")
@State private var editProfile: Bool = false
var body: some View {
let user: User = chatModel.currentUser!
return VStack(alignment: .leading) {
Text("Your chat profile")
.font(.title)
.padding(.bottom)
Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.")
.padding(.bottom)
if editProfile {
VStack(alignment: .leading) {
TextField("Display name", text: $profile.displayName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.bottom)
TextField("Full name (optional)", text: $profile.fullName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.bottom)
HStack(spacing: 20) {
Button("Cancel") { editProfile = false }
Button("Save (and notify contacts)") { saveProfile() }
}
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
} else {
VStack(alignment: .leading) {
HStack {
Text("Display name:")
Text(user.profile.displayName)
.fontWeight(.bold)
}
.padding(.bottom)
HStack {
Text("Full name:")
Text(user.profile.fullName)
.fontWeight(.bold)
}
.padding(.bottom)
Button("Edit") {
profile = user.profile
editProfile = true
}
}
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
}
}
.padding()
}
func saveProfile() {
do {
if let newProfile = try apiUpdateProfile(profile: profile) {
chatModel.currentUser?.profile = newProfile
profile = newProfile
}
} catch {
print(error)
}
editProfile = false
}
}
struct UserProfile_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = sampleUser
return UserProfile()
.environmentObject(chatModel)
}
}
-48
View File
@@ -1,48 +0,0 @@
//
// WelcomeView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 18/01/2022.
//
import SwiftUI
struct WelcomeView: View {
@EnvironmentObject var chatModel: ChatModel
@State var displayName: String = ""
@State var fullName: String = ""
var body: some View {
VStack(alignment: .leading) {
Text("Create profile")
.font(.largeTitle)
.padding(.bottom)
Text("Your profile is stored on your device and shared only with your contacts.\nSimpleX servers cannot see your profile.")
.padding(.bottom)
TextField("Display name", text: $displayName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.bottom)
TextField("Full name (optional)", text: $fullName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.bottom)
Button("Create") {
let profile = Profile(
displayName: displayName,
fullName: fullName
)
if let user = chatCreateUser(profile) {
chatModel.currentUser = user
}
}
}
.padding()
}
}
struct WelcomeView_Previews: PreviewProvider {
static var previews: some View {
WelcomeView()
}
}
-8
View File
@@ -1,8 +0,0 @@
//
// dummy.m
// SimpleX
//
// Created by Evgeny Poberezkin on 22/01/2022.
//
#import <Foundation/Foundation.h>
-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:simplex.chat</string>
<string>applinks:www.simplex.chat</string>
<string>applinks:simplex.chat?mode=developer</string>
</array>
</dict>
</plist>
-19
View File
@@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>chat.simplex.app</string>
<key>CFBundleURLSchemes</key>
<array>
<string>simplex</string>
</array>
</dict>
</array>
</dict>
</plist>
-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
File diff suppressed because it is too large Load Diff
@@ -1,16 +0,0 @@
{
"object": {
"pins": [
{
"package": "CodeScanner",
"repositoryURL": "https://github.com/twostraws/CodeScanner",
"state": {
"branch": null,
"revision": "c27a66149b7483fe42e2ec6aad61d5c3fffe522d",
"version": "2.1.1"
}
}
]
},
"version": 1
}
-42
View File
@@ -1,42 +0,0 @@
//
// Tests_iOS.swift
// Tests iOS
//
// Created by Evgeny Poberezkin on 17/01/2022.
//
import XCTest
class Tests_iOS: 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 its 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()
}
}
}
}
@@ -1,32 +0,0 @@
//
// Tests_iOSLaunchTests.swift
// Tests iOS
//
// Created by Evgeny Poberezkin on 17/01/2022.
//
import XCTest
class Tests_iOSLaunchTests: 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)
}
}
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>
-28
View File
@@ -1,28 +0,0 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Simplex.Chat
import Simplex.Chat.Controller (versionNumber)
import Simplex.Chat.Options
import Simplex.Chat.Terminal
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
putStrLn $ "SimpleX Chat v" ++ versionNumber
putStrLn $ "db: " <> dbFilePrefix <> "_chat.db, " <> dbFilePrefix <> "_agent.db"
putStrLn "type \"/help\" or \"/h\" for usage info"
pure opts
-19
View File
@@ -1,19 +0,0 @@
# Simplex chat
**Published:** Oct 22, 2020
https://simplex.chat
I'd really appreciate your feedback, criticism and suggestions on the open-source idea I was slowly working on since early 2020. I recently made the demo server for the low-level message queue protocol ("simplex messaging protocol") and the website to try to explain the chat idea that would use this protocol.
Haskell protocol implementation: https://github.com/simplex-chat/simplexmq
In short, the protocol defines a minimalist set of commands and server responses (just 7 commands and 5 responses sent over TCP) to operate encrypted message queues with in-memory persistence - the implementation uses STM.
If anything, it was definitely helping to get to know Haskell types etc. much deeper than before :)
Any criticism would be great - thank you in advance!
---
Originally published at https://www.reddit.com/r/haskell/comments/jg6uh4/simplex_chat/
-25
View File
@@ -1,25 +0,0 @@
## Announcing SimpleX Chat Prototype!
**Published:** May 12, 2021
For the last six months [me](https://github.com/epoberezkin) and my son [Efim](https://github.com/efim-poberezkin) have been working to bring you a working prototype of SimpleX Chat. We're excited to announce SimpleX Chat terminal client is now available [here](https://github.com/simplex-chat/simplex-chat) on Linux, Windows and Mac (you can either build from source or download the binary for Linux, Windows or Mac from the latest release).
Weve been using the terminal client between us and a few other people for a couple of months now, eating our own “dog food”, and have developed up to version 0.3.1, with most of the messaging protocol features we originally planned
### Features
- End-to-end encryption with protection from man in the middle attack. The connection invitation must be passed out-of-band (see [how to use SimpleX Chat](https://github.com/simplex-chat/simplex-chat#how-to-use-simplex-chat) in the repo).
- No global identity or any usernames visible to the server(s), ensuring full privacy of your contacts and conversations.
- Message signing and verification with automatically generated RSA keys, with keys being unique per each connection.
- Authorization of each command/message by the servers with automatically generated RSA key pairs, also unique per connection.
- Message integrity validation (via passing the digests of the previous messages).
- Encrypted TCP transport, independent of certificates.
- You can deploy your own server, but you dont have to - the demo SMP server to relay your messages is available at smp1.simplex.im:5223 (pre-configured in the client).
### We need your help!
We're building a new kind of chat network - the only network that lets you control your chat. We'd really appreciate your feedback, criticism and support - a star on the github repo, signing up to the mailing list or any contribution to the project will help. There is so much more to do!
---
Originally published at https://www.reddit.com/r/haskell/comments/naw6lz/simplex_chat_prototype_terminal_ui_made_in_haskell/
@@ -1,44 +0,0 @@
# SimpleX announces SimpleX Chat v0.4
## Open-source decentralized chat that uses privacy-preserving message routing protocol
**Published:** Sep 14, 2021
We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is our first application, a chat application built on the SimpleX platform that serves as an example of the power of the platform and as a reference application.
## What is SimpleX?
We recognised that there is currently no messaging application which respects user privacy and guarantees metadata privacy -- in other words, messages could be private, but a third party can always see who is communicating with whom by examining a central service and the connection graph. SimpleX, at it's core, is designed to be truly distributed with no central server. This allows for enormous scalability at low cost, and also makes it virtually impossible to snoop on the network graph.
The first application built on the platform is Simplex Chat, which for now is terminal (command line) based with mobile apps in the pipeline. The platform can easily support a private social network feed and a multitude of other services, which can be developed by the Simplex team or third party developers.
## What's new in v0.5?
We're exicted to announce that SimpleX Chat now supports group chat and file transfer!
### Chat groups
To create a group use the `/g <group>` command. You can then invite contacts to the group by entering the `/a <group> <name>` command. Your contact(s) will need to use the `/j accept` command to accept the invitation to the group. To send messages to the group, simply enter `#<group> <message>`.
**Please note:** Groups are not stored on any server; they are maintained as a list of members in the app database. Sending a message to the group sends a message to each member of the group.
![simplex-chat](../images/groups.gif)
### File transfer
Sharing files is simple! To send a file to a contact, use the `/f @<contact> <file_path>` command. The recipient will have to accept before the file is sent.
![simplex-chat](../images/files.gif)
## We're always looking for help!
We'd really appreciate your comments, criticism and support - a star on the GitHub repo, downloading and testing the chat or any contribution to the project will help a lot thank you for all your support!
**Please note:** SimpleX Chat is in early stage development: we are still iterating protocols, improving privacy and security, so if you have communication scenarios requiring high security, you should consider some other options for now.
Our goal is to create a new kind of chat platform that lets you control your chat!
---
Originally published at https://www.reddit.com/r/selfhosted/comments/poal79/simplex_chat_an_opensource_decentralized_chat/
@@ -1,32 +0,0 @@
# SimpleX announces SimpleX Chat v0.5
## Simplex Chat is the first chat platform that is 100% private by design - SimpleX no access to your connections graph
**Published:** Dec 08, 2021
We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is our first application, a chat application built on the SimpleX platform that serves as an example of the power of the platform and as a reference application.
## What is SimpleX?
We recognised that there is currently no messaging application which respects user privacy and guarantees metadata privacy -- in other words, messages could be private, but a third party can always see who is communicating with whom by examining a central service and the connection graph. SimpleX, at it's core, is designed to be truly distributed with no central server. This allows for enormous scalability at low cost, and also makes it virtually impossible to snoop on the network graph.
The first application built on the platform is Simplex Chat, which for now is terminal (command line) based with mobile apps in the pipeline. The platform can easily support a private social network feed and a multitude of other services, which can be developed by the Simplex team or third party developers.
## What's new in v0.5?
### Long-term chat addresses
Users can now create long-term chat addresses that they can share with many people (e.g. in email signature, or online), so that any chat user can send them a connection request.
This is an ALPHA feature, and we have not yet added any protection against spam contact requests. However, if the address you created starts receiving spam connection requests, you can simply delete it without losing any of your accepted connections and create another address - as many times as you like!
## We need your help!
We'd really appreciate your comments, criticism and support - a star on the GitHub repo, downloading and testing the chat or any contribution to the project will help a lot thank you for all your support!
**Please note:** SimpleX Chat is in early stage development: we are still iterating protocols, improving privacy and security, so if you have communication scenarios requiring high security, you should consider some other options for now.
Our goal is to create a new kind of chat platform that lets you control your chat!
---
Originally published at https://www.reddit.com/r/haskell/comments/rc0xkn/simplex_chat_the_first_chat_platform_that_is_100/
-53
View File
@@ -1,53 +0,0 @@
# SimpleX announces SimpleX Chat v1
**Published:** Jan 12, 2022
## The most private and secure chat and application platform
We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat) is our first application, a messaging application built on the SimpleX platform.
## What is SimpleX?
There is currently no messaging application which respects user privacy and guarantees metadata privacy - in other words, messages could be private, but a third party can always see who is communicating with whom by examining a central service and the connection graph. SimpleX, at it's core, is designed to be truly distributed with no central server. This allows for enormous scalability at low cost, and also makes it virtually impossible to snoop on the network graph.
The first application built on the platform is Simplex Chat, which for now is terminal (command line) based with mobile apps in the pipeline. The platform can easily support a private social network feed and a multitude of other services, which can be developed by the Simplex team or third party developers.
## What's new in v1?
### Stable protocol implementation
All releases from v1 onwards will be forwards and backwards compatible.
### Message encryption has been completely re-engineered to provide forward secrecy and recovery from break-in.
SimpleX Chat v1 now uses:
- [double-ratchet](https://www.signal.org/docs/specifications/doubleratchet/) E2E encryption using AES-256-GCM cipher with [X3DH key agreement](https://www.signal.org/docs/specifications/x3dh/) using 2 ephemeral Curve448 keys to derive secrets for ratchet initialization. These keys and secrets are separate for each contact, group membership and file transfer.
- in addition to double ratchet, there is a separate E2E encryption in each message queue with DH key exchange using Curve25519 and [NaCl crypto-box](https://nacl.cr.yp.to/index.html) - separate E2E encryption has been added to avoid having any cipher-text in common between message queues of a single contact (to prevent traffic correlation).
- additional encryption of messages delivered from servers to recipients, also using Curve25519 DH exchange and NaCl crypto-box - to avoid shared cipher-text in sent and received traffic (also to prevent traffic correlation).
### Improved user and server authentication and transport
SimpleX now uses ephemeral Ed448 keys to sign and verify client commands to the servers. As before, these keys are different per message queue and do not represent a user's identity.
Instead of ad-hoc encrypted transport we now use TLS 1.2+ limited to the most performant and secure cipher with forward secrecy (ECDHE-ECDSA-CHACHA20POLY1305-SHA256), Curve448 groups and Ed448 keys.
Server identity is validated as part of TLS handshake - the fingerprint of offline server certificate is used as a permanent server identity which is included in server address, to protect against MITM attacks between clients and servers.
SimpleX also uses [tls-unique channel binding](https://datatracker.ietf.org/doc/html/rfc5929#section-3) in each signed client command to the server to protect against replay attacks.
### Changes in protocol encoding
We switched from inefficient text-based low level protocol encodings, that simplified early development, to space and performance efficient binary encodings, reducing protocol overhead from circa 15% to 3.7% of transmitted application message size.
## Learn more about Simplex
Further details on platform objectives and technical design are available [here](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md).
SimpleX Chat client can be used in the terminal on all major desktop platforms (Windows/Mac/Linux) and also on Android devices with [Termux](https://github.com/termux).
SimpleX also allows people to host their own servers and own their own chat data. SimpleX servers are exceptionally lightweight and require a single process with the initial memory footprint of under 20 Mb, which grows as the server adds in-memory queues (even with 10,000 queues it uses less than 50Mb, not accounting for messages).
## We look forward to you using it!
We look forward to your feedback and suggestions - via GitHub issues or via SimpleX Chat - you can connect to the team with `/simplex` command once you run the chat.
-11
View File
@@ -1,11 +0,0 @@
# Blog
Jan 12, 2022. [SimpleX Chat v1 released: the most private and secure chat and application platform](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220112-simplex-chat-v1-released.md)
Dec 08, 2021. [SimpleX Chat v0.5 released: the first chat platform that is 100% private by design - no access to your connections graph](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20211208-simplex-chat-v0.5-released.md)
Sep 14, 2021. [SimpleX Chat v0.4 released: open-source chat that uses privacy-preserving message routing protocol](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20210914-simplex-chat-v0.4-released.md)
May 12, 2021. [SimpleX Chat Prototype!](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20210512-simplex-chat-terminal-ui.md)
Oct 22, 2020. [SimpleX Chat](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20201022-simplex-chat)
-21
View File
@@ -1,21 +0,0 @@
packages: .
source-repository-package
type: git
location: git://github.com/simplex-chat/simplexmq.git
tag: 137ff7043d49feb3b350f56783c9b64a62bc636a
source-repository-package
type: git
location: git://github.com/simplex-chat/aeson.git
tag: 3eb66f9a68f103b5f1489382aad89f5712a64db7
source-repository-package
type: git
location: git://github.com/simplex-chat/haskell-terminal.git
tag: f708b00009b54890172068f168bf98508ffcd495
source-repository-package
type: git
location: git://github.com/zw3rk/android-support.git
tag: 3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb
-15
View File
@@ -1,15 +0,0 @@
diff --git a/direct-sqlite.cabal b/direct-sqlite.cabal
index 96f26b7..996198e 100644
--- a/direct-sqlite.cabal
+++ b/direct-sqlite.cabal
@@ -69,7 +69,9 @@ library
install-includes: sqlite3.h, sqlite3ext.h
include-dirs: cbits
- if !os(windows) && !os(android)
+ extra-libraries: dl
+
+ if !os(windows) && !os(android)
extra-libraries: pthread
if flag(fulltextsearch)
Generated
-341
View File
@@ -1,341 +0,0 @@
{
"nodes": {
"HTTP": {
"flake": false,
"locked": {
"lastModified": 1451647621,
"narHash": "sha256-oHIyw3x0iKBexEo49YeUDV1k74ZtyYKGR2gNJXXRxts=",
"owner": "phadej",
"repo": "HTTP",
"rev": "9bc0996d412fef1787449d841277ef663ad9a915",
"type": "github"
},
"original": {
"owner": "phadej",
"repo": "HTTP",
"type": "github"
}
},
"cabal-32": {
"flake": false,
"locked": {
"lastModified": 1603716527,
"narHash": "sha256-sDbrmur9Zfp4mPKohCD8IDZfXJ0Tjxpmr2R+kg5PpSY=",
"owner": "haskell",
"repo": "cabal",
"rev": "94aaa8e4720081f9c75497e2735b90f6a819b08e",
"type": "github"
},
"original": {
"owner": "haskell",
"ref": "3.2",
"repo": "cabal",
"type": "github"
}
},
"cabal-34": {
"flake": false,
"locked": {
"lastModified": 1622475795,
"narHash": "sha256-chwTL304Cav+7p38d9mcb+egABWmxo2Aq+xgVBgEb/U=",
"owner": "haskell",
"repo": "cabal",
"rev": "b086c1995cdd616fc8d91f46a21e905cc50a1049",
"type": "github"
},
"original": {
"owner": "haskell",
"ref": "3.4",
"repo": "cabal",
"type": "github"
}
},
"cabal-36": {
"flake": false,
"locked": {
"lastModified": 1640163203,
"narHash": "sha256-TwDWP2CffT0j40W6zr0J1Qbu+oh3nsF1lUx9446qxZM=",
"owner": "haskell",
"repo": "cabal",
"rev": "ecf418050c1821f25e2e218f1be94c31e0465df1",
"type": "github"
},
"original": {
"owner": "haskell",
"ref": "3.6",
"repo": "cabal",
"type": "github"
}
},
"cardano-shell": {
"flake": false,
"locked": {
"lastModified": 1608537748,
"narHash": "sha256-PulY1GfiMgKVnBci3ex4ptk2UNYMXqGjJOxcPy2KYT4=",
"owner": "input-output-hk",
"repo": "cardano-shell",
"rev": "9392c75087cb9a3d453998f4230930dea3a95725",
"type": "github"
},
"original": {
"owner": "input-output-hk",
"repo": "cardano-shell",
"type": "github"
}
},
"flake-utils": {
"locked": {
"lastModified": 1638122382,
"narHash": "sha256-sQzZzAbvKEqN9s0bzWuYmRaA03v40gaJ4+iL1LXjaeI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "74f7e4319258e287b0f9cb95426c9853b282730b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1623875721,
"narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f7e004a55b120c02ecb6219596820fcd32ca8772",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"ghc-8.6.5-iohk": {
"flake": false,
"locked": {
"lastModified": 1600920045,
"narHash": "sha256-DO6kxJz248djebZLpSzTGD6s8WRpNI9BTwUeOf5RwY8=",
"owner": "input-output-hk",
"repo": "ghc",
"rev": "95713a6ecce4551240da7c96b6176f980af75cae",
"type": "github"
},
"original": {
"owner": "input-output-hk",
"ref": "release/8.6.5-iohk",
"repo": "ghc",
"type": "github"
}
},
"hackage": {
"flake": false,
"locked": {
"lastModified": 1642986764,
"narHash": "sha256-U6FPiNjz9JctwKC838LEoT/xjGfb8L18ZGIEY5YYzdU=",
"owner": "input-output-hk",
"repo": "hackage.nix",
"rev": "22406c79a506164c4e835a68e54739f63f918784",
"type": "github"
},
"original": {
"owner": "input-output-hk",
"repo": "hackage.nix",
"type": "github"
}
},
"haskellNix": {
"inputs": {
"HTTP": "HTTP",
"cabal-32": "cabal-32",
"cabal-34": "cabal-34",
"cabal-36": "cabal-36",
"cardano-shell": "cardano-shell",
"flake-utils": "flake-utils_2",
"ghc-8.6.5-iohk": "ghc-8.6.5-iohk",
"hackage": "hackage",
"hpc-coveralls": "hpc-coveralls",
"nix-tools": "nix-tools",
"nixpkgs": [
"nixpkgs"
],
"nixpkgs-2003": "nixpkgs-2003",
"nixpkgs-2105": "nixpkgs-2105",
"nixpkgs-2111": "nixpkgs-2111",
"nixpkgs-unstable": "nixpkgs-unstable",
"old-ghc-nix": "old-ghc-nix",
"stackage": "stackage"
},
"locked": {
"lastModified": 1643019329,
"narHash": "sha256-So77czYvvD0jt4GJeypkqw3VNn20ype5tHnHri2s5lg=",
"owner": "input-output-hk",
"repo": "haskell.nix",
"rev": "ddc654e2e7e44617bfc17a5aed2a0947d3e192cc",
"type": "github"
},
"original": {
"owner": "input-output-hk",
"ref": "angerman/android-static",
"repo": "haskell.nix",
"type": "github"
}
},
"hpc-coveralls": {
"flake": false,
"locked": {
"lastModified": 1607498076,
"narHash": "sha256-8uqsEtivphgZWYeUo5RDUhp6bO9j2vaaProQxHBltQk=",
"owner": "sevanspowell",
"repo": "hpc-coveralls",
"rev": "14df0f7d229f4cd2e79f8eabb1a740097fdfa430",
"type": "github"
},
"original": {
"owner": "sevanspowell",
"repo": "hpc-coveralls",
"type": "github"
}
},
"nix-tools": {
"flake": false,
"locked": {
"lastModified": 1636018067,
"narHash": "sha256-ng306fkuwr6V/malWtt3979iAC4yMVDDH2ViwYB6sQE=",
"owner": "input-output-hk",
"repo": "nix-tools",
"rev": "ed5bd7215292deba55d6ab7a4e8c21f8b1564dda",
"type": "github"
},
"original": {
"owner": "input-output-hk",
"repo": "nix-tools",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1641457028,
"narHash": "sha256-bA31xSpdSIo+rJMbHPurlxIsP/b6bbN+jvXOqyn2lR8=",
"owner": "angerman",
"repo": "nixpkgs",
"rev": "7b049e87e9b371f9ea6648aa8f1f2d17b2e31ae5",
"type": "github"
},
"original": {
"owner": "angerman",
"ref": "patch-1",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-2003": {
"locked": {
"lastModified": 1620055814,
"narHash": "sha256-8LEHoYSJiL901bTMVatq+rf8y7QtWuZhwwpKE2fyaRY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1db42b7fe3878f3f5f7a4f2dc210772fd080e205",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-20.03-darwin",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-2105": {
"locked": {
"lastModified": 1640283157,
"narHash": "sha256-6Ddfop+rKE+Gl9Tjp9YIrkfoYPzb8F80ergdjcq3/MY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "dde1557825c5644c869c5efc7448dc03722a8f09",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-21.05-darwin",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-2111": {
"locked": {
"lastModified": 1640283207,
"narHash": "sha256-SCwl7ZnCfMDsuSYvwIroiAlk7n33bW8HFfY8NvKhcPA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c7e3388bbd9206e437713351e814366e0c3284",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-21.11-darwin",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1641285291,
"narHash": "sha256-KYaOBNGar3XWTxTsYPr9P6u74KAqNq0wobEC236U+0c=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0432195a4b8d68faaa7d3d4b355260a3120aeeae",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"old-ghc-nix": {
"flake": false,
"locked": {
"lastModified": 1631092763,
"narHash": "sha256-sIKgO+z7tj4lw3u6oBZxqIhDrzSkvpHtv0Kki+lh9Fg=",
"owner": "angerman",
"repo": "old-ghc-nix",
"rev": "af48a7a7353e418119b6dfe3cd1463a657f342b8",
"type": "github"
},
"original": {
"owner": "angerman",
"ref": "master",
"repo": "old-ghc-nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"haskellNix": "haskellNix",
"nixpkgs": "nixpkgs"
}
},
"stackage": {
"flake": false,
"locked": {
"lastModified": 1642986888,
"narHash": "sha256-oxG7LzlJdjKTJgSv7diKWsGTETDZMPT2mNNLbrBfiVs=",
"owner": "input-output-hk",
"repo": "stackage.nix",
"rev": "aeaf5fe21874f01702f394d01e18f472be6e3e08",
"type": "github"
},
"original": {
"owner": "input-output-hk",
"repo": "stackage.nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
-165
View File
@@ -1,165 +0,0 @@
{
description = "nix flake for simplex-chat";
inputs.nixpkgs.url = "github:angerman/nixpkgs/patch-1"; # based on 21.11, still need this, until everything is merged into 21.11.
inputs.haskellNix.url = "github:input-output-hk/haskell.nix?ref=angerman/android-static";
inputs.haskellNix.inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, haskellNix, nixpkgs, flake-utils }:
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 {
compiler-nix-name = "ghc8107";
index-state = "2022-01-24T00:00:00Z";
# We need this, to specify we want the cabal project.
# If the stack.yaml was dropped, this would not be necessary.
projectFileName = "cabal.project";
src = pkgs.haskell-nix.haskellLib.cleanGit {
name = "simplex-chat";
src = ./.;
};
sha256map = import ./sha256map.nix;
modules = [{
packages.direct-sqlite.patches = [ ./direct-sqlite-2.3.26.patch ];
}
({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) {
packages.simplex-chat.components.library.ghcOptions = [ "-pie" ];
})];
}; 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: {
postInstall = ''
mkdir -p $out/_pkg
find $out/lib -name "*.a" -exec cp {} $out/_pkg \;
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
}); in
rec {
packages = {
"lib:simplex-chat" = (drv pkgs).simplex-chat.components.library;
"exe:simplex-chat" = (drv pkgs).simplex-chat.components.exes.simplex-chat;
} // ({
"x86_64-linux" =
let
androidPkgs = pkgs.pkgsCross.aarch64-android;
# For some reason building libiconv with nixpgks android setup produces
# LANGINFO_CODESET to be found, which is not compatible with android sdk 23;
# so we'll patch up iconv to not include that.
androidIconv = (androidPkgs.libiconv.override { enableStatic = true; }).overrideAttrs (old: {
postConfigure = ''
echo "#undef HAVE_LANGINFO_CODESET" >> libcharset/config.h
echo "#undef HAVE_LANGINFO_CODESET" >> lib/config.h
'';
});
# Similarly to icovn, for reasons beyond my current knowledge, nixpkgs andorid
# toolchain makes configure believe we have MEMFD_CREATE, which we don't in
# sdk 23.
androidFFI = androidPkgs.libffi.overrideAttrs (old: {
dontDisableStatic = true;
hardeningDisable = [ "fortify" ];
postConfigure = ''
echo "#undef HAVE_MEMFD_CREATE" >> aarch64-unknown-linux-android/fficonfig.h
'';
}
);in {
"aarch64-android:lib:support" = (drv androidPkgs).android-support.components.library.override {
smallAddressSpace = true; enableShared = false;
setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsupport.so" ];
postInstall = ''
mkdir -p $out/_pkg
cp libsupport.so $out/_pkg
${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsupport.so
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
"aarch64-android:lib:simplex-chat" = (drv androidPkgs).simplex-chat.components.library.override {
smallAddressSpace = true; enableShared = false;
# for android we build a shared library, passing these arguments is a bit tricky, as
# we want only the threaded rts (HSrts_thr) and ffi to be linked, but not fed into iserv for
# template haskell cross compilation. Thus we just pass them as linker options (-optl).
setupBuildFlags = map (x: "--ghc-option=${x}") [ "-shared" "-o" "libsimplex.so" "-optl-lHSrts_thr" "-optl-lffi"];
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.
cp libsimplex.so $out/_pkg
# find ./dist -name "lib*.so" -exec cp {} $out/_pkg \;
# find ./dist -name "libHS*-ghc*.a" -exec cp {} $out/_pkg \;
# find ${androidFFI}/lib -name "*.a" -exec cp {} $out/_pkg \;
# find ${androidPkgs.gmp6.override { withStatic = true; }}/lib -name "*.a" -exec cp {} $out/_pkg \;
# find ${androidIconv}/lib -name "*.a" -exec cp {} $out/_pkg \;
# find ${androidPkgs.stdenv.cc.libc}/lib -name "*.a" -exec cp {} $out/_pkg \;
${pkgs.patchelf}/bin/patchelf --remove-needed libunwind.so.1 $out/_pkg/libsimplex.so
${pkgs.tree}/bin/tree $out/_pkg
(cd $out/_pkg; ${pkgs.zip}/bin/zip -r -9 $out/pkg.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
};
"aarch64-darwin" = {
"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.
# for iOS we also use -staticlib, to get one rolled up library.
# still needs mac2ios patching of the archives.
ghcOptions = [ "-staticlib" "-threaded" ];
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.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
};
}.${system} or {});
# build all packages in hydra.
hydraJobs = packages;
devShell = let
updateCmd = pkgs.writeShellApplication {
name = "update-sha256map";
runtimeInputs = [ pkgs.nix-prefetch-git pkgs.jq pkgs.gawk ];
text = ''
gawk -f update-sha256.awk cabal.project > sha256map.nix
'';
}; in
pkgs.mkShell {
buildInputs = [ updateCmd ];
shellHook = ''
echo "welcome to the shell!"
'';
};
}
);
}
View File
+232
View File
@@ -0,0 +1,232 @@
<img align="right" src="images/logo.svg" alt="SimpleX logo" height="90">
# SimpleX chat (legacy Haskell terminal prototype)
## Private, secure, decentralized
[![GitHub build](https://github.com/simplex-chat/simplex-chat/workflows/build/badge.svg)](https://github.com/simplex-chat/simplex-chat/actions?query=workflow%3Abuild)
[![GitHub release](https://img.shields.io/github/v/release/simplex-chat/simplex-chat)](https://github.com/simplex-chat/simplex-chat/releases)
> **NEW in v0.4: [groups](#groups) and [sending files](#sending-files)!**
The motivation for SimpleX chat is [presented here](./simplex.md).
SimpleX chat prototype is a thin terminal UI on top of [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker that uses [SMP protocols](https://github.com/simplex-chat/simplexmq/blob/master/protocol).
See [simplex.chat](https://simplex.chat) website for chat demo and the explanations of the system and how SMP protocol works.
![simplex-chat](./images/connection.gif)
## Table of contents
- [Disclaimer](#disclaimer)
- [Network topology](#network-topology)
- [Terminal chat features](#terminal-chat-features)
- [Installation](#installation)
- [Download chat client](#download-chat-client)
- [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)
- [Access chat history](#access-chat-history)
- [Future roadmap](#future-roadmap)
- [License](#license)
## Disclaimer
This is WIP implementation of SimpleX chat that implements a new network topology for asynchronous communication combining the advantages and avoiding the disadvantages of federated and P2P networks.
If you expect a software being reliable most of the time and doing something useful, then this is probably not ready for you yet. We do use it for terminal chat though, and it seems to work most of the time - we would really appreciate if you try it and give us your feedback.
**Please note:** The main differentiation of SimpleX network is the approach to internet message routing rather than encryption; for that reason no sufficient attention was paid to either TCP transport level encryption or to E2E encryption protocols - they are implemented in an ad hoc way based on RSA and AES algorithms. See [SMP protocol](https://github.com/simplex-chat/simplexmq/blob/master/protocol/simplex-messaging.md#appendix-a) on TCP transport encryption protocol (AEAD-GCM scheme, with an AES key negotiation based on RSA key hash known to the client in advance) and [this section](https://github.com/simplex-chat/simplexmq/blob/master/rfcs/2021-01-26-crypto.md#e2e-encryption) on E2E encryption protocol (an ad hoc hybrid scheme a la PGP). These protocols will change in a consumer ready version to something more robust.
## Network topology
SimpleX is a decentralized client-server network that uses redundant, disposable nodes to asynchronously pass the 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 - it avoids the problem of metadata visibility that federated networks suffer from 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 RSA 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.
- 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.
- E2E encryption, with RSA public key that has to be passed out-of-band (see [How to use SimpleX chat](#how-to-use-simplex-chat)).
- Message signing and verification with automatically generated RSA keys.
- Message integrity validation (via including the digests of the previous messages).
- Authentication of each command/message by SMP servers with automatically generated RSA key pairs.
- TCP transport encryption using SMP transport protocol.
RSA keys are not used as identity, they are randomly generated for each contact.
## Installation
### Download chat client
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.
#### Linux and MacOS
```sh
chmod +x <binary>
mv <binary> ~/.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 <binary> %APPDATA%/local/bin/simplex-chat.exe
```
### Build from source
#### 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
$ 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.8.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
$ 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.chat.db` and `simplex.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.chat.db` and `alice.agent.db` database files in current directory.
Default SMP servers are hosted on Linode (London, UK and Fremont, CA) - they are [pre-configured in the app](https://github.com/simplex-chat/simplex-chat/blob/master/src/Simplex/Chat/Options.hs#L40). Base-64 encoded string after server host is the transport key digest.
If you deployed your own SMP server(s) you can configure client via `-s` option:
```shell
$ simplex-chat -s smp.example.com:5223#KXNE1m2E1m0lm92WGKet9CL6+lO742Vy5G6nsrkvgs8=
```
The base-64 encoded string in server address is the digest of RSA transport handshake key that the server will generate on the first run and output its digest.
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. In case different contacts chose the same display name, the chat client adds a numeric suffix to their local display names.
This diagram shows how to connect and message a contact:
<div align="center">
<img align="center" src="images/how-to-use-simplex.svg">
</div>
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 has the format `smp::<server>::<queue_id>::<rsa_public_key_for_this_queue_only>`. 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.
The contact who received the invitation should enter `/c <invitation>` to accept the connection. This establishes the connection, and both parties are notified.
They would then use `@<name> <message>` 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 <group>`, then add contacts to it with `/a <group> <name>`and send messages with `#<group> <message>`. Use `/help groups` for other commands.
![simplex-chat](./images/groups.gif)
> **Please note**: the groups are not stored on any server, they are maintained as a list of members in the app database to whom the messages will be sent.
### Sending files
You can send a file to your contact with `/f @<contact> <file_path>` - the recipient will have to accept it before it is sent. Use `/help files` for other commands.
![simplex-chat](./images/files.gif)
You can send files to a group with `/f #<group> <file_path>`.
### Access chat history
> 🚧 **Section currently out of date - will be updated soon** 🏗
SimpleX chat stores all your contacts and conversations in a local database file, making it private and portable by design, fully owned and controlled by you.
You can search your chat history via SQLite database file:
```
sqlite3 ~/.simplex/smp-chat.db
```
Now you can query `messages` table, for example:
```sql
select * from messages
where conn_alias = cast('alice' as blob)
and body like '%cats%'
order by internal_id desc;
```
> **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.
## Future 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.
## License
[AGPL v3](./LICENSE)
+119
View File
@@ -0,0 +1,119 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
module Demo where
import Simplex.Chat.Styled
import System.Console.ANSI.Types
import System.Terminal
someViewUpdate :: Monad m => m ()
someViewUpdate = pure ()
chatLayoutDemo :: MonadTerminal m => m ()
chatLayoutDemo =
mapM_
putStyledLn
[ " search " <> Styled gray "(ctrl-s) " <> lineV <> Styled toContact " @bob " <> "Bob Roberts " <> Styled greenColor "@john" <> "",
" " <> lineV <> Styled gray " 14:15 online profile (ctrl-p)",
lineH 20 <> crossover <> lineH 59,
"* " <> Styled [SetConsoleIntensity BoldIntensity] "all chats " <> " " <> lineV <> "",
Styled gray " (ctrl-a) " <> lineV <> "",
"*" <> Styled toContact " @alice " <> Styled darkGray "14:37 " <> lineV <> "",
Styled gray " Hello there! ... " <> lineV <> "",
Styled selected " " <> Styled (toContact <> selected) " @bob " <> Styled (selected <> gray) "12:35 " <> lineV <> "",
Styled selected " All good, John... " <> lineV <> "",
"*" <> Styled group " #team " <> Styled darkGray "10:55 " <> lineV <> "",
Styled gray " What's up ther... " <> lineV <> "",
" " <> Styled toContact " @tom " <> Styled darkGray "Wed " <> lineV <> "",
Styled gray " Have you seen ... " <> lineV <> "",
" " <> lineV,
" " <> lineV,
" " <> lineV,
" " <> lineV,
" " <> lineV,
" " <> lineV <> Styled greenColor " ✔︎" <> Styled darkGray " 12:30" <> Styled toContact " @bob" <> " hey bob - how is it going?",
" " <> lineV <> Styled greenColor " ✔︎" <> Styled darkGray " " <> Styled toContact " " <> " let's meet soon!",
" " <> lineV <> " *" <> Styled darkGray " 12:35" <> Styled contact " bob>" <> " All good, John! How are you?",
" " <> teeL <> lineH 59,
" " <> lineV <> " > " <> Styled toContact "@bob" <> " 😀 This is the message that will be sent to @bob"
]
>> putStyled (Styled ctrlKeys " help (ctrl-h) new contact (ctrl-n) choose chat (ctrl-↓↑) new group (ctrl-g) ")
contact :: [SGR]
contact = [SetConsoleIntensity BoldIntensity, SetColor Foreground Vivid Yellow]
toContact :: [SGR]
toContact = [SetConsoleIntensity BoldIntensity, SetColor Foreground Vivid Cyan]
group :: [SGR]
group = [SetConsoleIntensity BoldIntensity, SetColor Foreground Vivid Cyan]
selected :: [SGR]
selected = [SetColor Background Vivid Black]
ctrlKeys :: [SGR]
ctrlKeys = [SetColor Background Dull White, SetColor Foreground Dull Black]
gray :: [SGR]
gray = [SetColor Foreground Dull White]
darkGray :: [SGR]
darkGray = [SetColor Foreground Vivid Black]
greenColor :: [SGR]
greenColor = [SetColor Foreground Vivid Green]
lineV :: StyledString
lineV = Styled selected " " -- "\x2502"
lineH :: Int -> StyledString
lineH n = Styled darkGray $ replicate n '\x2500'
teeL :: StyledString
teeL = Styled selected " " -- "\x251C"
crossover :: StyledString
crossover = Styled selected " " -- "\x253C"
putStyledLn :: MonadTerminal m => StyledString -> m ()
putStyledLn s = putStyled s >> putLn
putStyled :: MonadTerminal m => StyledString -> m ()
putStyled (s1 :<>: s2) = putStyled s1 >> putStyled s2
putStyled (Styled [] s) = putString s
putStyled (Styled sgr s) = setSGR sgr >> putString s >> resetAttributes
setSGR :: MonadTerminal m => [SGR] -> m ()
setSGR = mapM_ $ \case
Reset -> resetAttributes
SetConsoleIntensity BoldIntensity -> setAttribute bold
SetConsoleIntensity _ -> resetAttribute bold
SetItalicized True -> setAttribute italic
SetItalicized _ -> resetAttribute italic
SetUnderlining NoUnderline -> resetAttribute underlined
SetUnderlining _ -> setAttribute underlined
SetSwapForegroundBackground True -> setAttribute inverted
SetSwapForegroundBackground _ -> resetAttribute inverted
SetColor l i c -> setAttribute . layer l . intensity i $ color c
SetBlinkSpeed _ -> pure ()
SetVisible _ -> pure ()
SetRGBColor _ _ -> pure ()
SetPaletteColor _ _ -> pure ()
SetDefaultColor _ -> pure ()
where
layer = \case
Foreground -> foreground
Background -> background
intensity = \case
Dull -> id
Vivid -> bright
color = \case
Black -> black
Red -> red
Green -> green
Yellow -> yellow
Blue -> blue
Magenta -> magenta
Cyan -> cyan
White -> white
+58
View File
@@ -0,0 +1,58 @@
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Simplex.Chat
import Simplex.Chat.Options
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 {dbFile} <- getChatOpts appDir
putStrLn "SimpleX chat prototype v0.4.0"
putStrLn $ "db: " <> dbFile <> ".chat.db, " <> dbFile <> ".agent.db"
putStrLn "type \"/help\" or \"/h\" for usage info"
pure opts
-- defaultSettings :: C.Size -> C.VirtualTerminalSettings
-- defaultSettings size =
-- C.VirtualTerminalSettings
-- { C.virtualType = "xterm",
-- C.virtualWindowSize = pure size,
-- C.virtualEvent = retry,
-- C.virtualInterrupt = retry
-- }
-- main :: IO ()
-- main = do
-- void $ createStore "simplex-chat.db" 4
-- hFlush stdout
-- -- ChatTerminal {termSize} <- newChatTerminal
-- -- pos <- C.withVirtualTerminal (defaultSettings termSize) $
-- -- \t -> runTerminalT (C.setAlternateScreenBuffer True >> C.putString "a" >> C.flush >> C.getCursorPosition) t
-- -- print pos
-- -- race_ (printEvents t) (updateTerminal t)
-- void . withTerminal . runTerminalT $ chatLayoutDemo >> C.flush >> C.awaitEvent
-- printEvents :: C.VirtualTerminal -> IO ()
-- printEvents t = forever $ do
-- event <- withTerminal . runTerminalT $ C.flush >> C.awaitEvent
-- runTerminalT (putStringLn $ show event) t
-- updateTerminal :: C.VirtualTerminal -> IO ()
-- updateTerminal t = forever $ do
-- threadDelay 10000
-- win <- readTVarIO $ C.virtualWindow t
-- withTerminal . runTerminalT $ mapM_ C.putStringLn win >> C.flush
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1016 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 332 KiB

+12
View File
@@ -0,0 +1,12 @@
<svg width="815" height="233" viewBox="0 0 815 233" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M40.5683 66.9141C43.9954 66.9141 47.376 67.2323 50.7104 67.8687C54.0448 68.5051 57.2403 69.3234 60.2968 70.3235C63.3533 71.3236 66.2014 72.4601 68.8411 73.7329C71.4809 75.0058 73.9122 76.2786 76.1351 77.5515L66.132 96.0987L65.9952 95.9474C65.6001 95.5596 64.6732 94.9282 63.2144 94.0531C61.5472 93.053 59.4632 92.0074 56.9624 90.9164C54.4616 89.8254 51.7293 88.8708 48.7654 88.0525C45.8015 87.2342 42.8376 86.8251 39.8737 86.8251C31.723 86.8251 27.6476 89.5072 27.6476 94.8713C27.6476 96.5079 28.0876 97.8716 28.9675 98.9627C29.8474 100.054 31.1441 101.031 32.8576 101.895C34.5711 102.758 36.7245 103.554 39.318 104.281C41.9114 105.009 44.9216 105.827 48.3486 106.736C53.0723 108.009 57.3329 109.395 61.1304 110.896C64.9279 112.396 68.1465 114.26 70.7862 116.487C73.4259 118.715 75.4636 121.419 76.8993 124.602C78.3349 127.784 79.0527 131.602 79.0527 136.057C79.0527 141.512 78.0107 146.126 75.9267 149.899C73.8427 153.673 71.0872 156.718 67.6602 159.037C64.2332 161.355 60.2968 163.037 55.8509 164.083C51.4051 165.128 46.8203 165.651 42.0966 165.651C38.4844 165.651 34.7795 165.378 30.982 164.833C27.1845 164.287 23.4796 163.492 19.8674 162.446C16.2551 161.401 12.7587 160.15 9.37796 158.696C5.99726 157.241 2.87128 155.559 0 153.65L10.0032 134.148L10.1812 134.338C10.6877 134.825 11.8324 135.625 13.6154 136.739C15.6531 138.012 18.177 139.285 21.1872 140.558C24.1974 141.83 27.555 142.967 31.2599 143.967C34.9647 144.967 38.7159 145.467 42.5134 145.467C50.5715 145.467 54.6006 143.058 54.6006 138.239C54.6006 136.421 53.9985 134.921 52.7944 133.739C51.5903 132.557 49.9231 131.489 47.7928 130.534C45.6625 129.579 43.1386 128.693 40.221 127.875C37.3034 127.056 34.1311 126.147 30.7041 125.147C26.1657 123.783 22.2292 122.306 18.8948 120.715C15.5605 119.124 12.805 117.283 10.6284 115.192C8.45174 113.1 6.83086 110.691 5.76571 107.964C4.70056 105.236 4.16798 102.054 4.16798 98.4171C4.16798 93.3257 5.14051 88.8253 7.08557 84.9158C9.03063 81.0063 11.6703 77.7106 15.0047 75.0285C18.3391 72.3464 22.2061 70.3235 26.6056 68.9597C31.0051 67.5959 35.6594 66.9141 40.5683 66.9141ZM131.47 67.7323V164.56H108.685V67.7323H131.47ZM191.39 67.7323L216.954 118.328L242.657 67.7323H267.248V164.56H244.463V106.6L223.067 148.74H210.841L189.445 106.6V164.56H166.66V67.7323H191.39ZM419.925 67.7323V144.922H467.718V164.56H397.14V67.7323H419.925ZM560.072 67.7323V87.3706H514.78V106.191H553.681V124.329H514.78V144.922H561.323V164.56H491.995V67.7323H560.072ZM341.818 67.7323L343.378 67.7677C347.5 67.9563 351.31 68.8991 354.809 70.5962C358.745 72.5055 362.126 75.0058 364.951 78.097C367.776 81.1882 369.999 84.6885 371.62 88.598C373.24 92.5075 374.051 96.4624 374.051 100.463C374.051 104.736 373.287 108.827 371.758 112.737C370.23 116.646 368.1 120.147 365.368 123.238C362.635 126.329 359.324 128.784 355.434 130.602C351.976 132.218 348.189 133.116 344.072 133.296L342.513 133.33H322.507V164.56H299.722V67.7323H341.818ZM340.29 87.3706H322.507V113.828H341.124C343.81 113.828 346.125 112.691 348.07 110.418C350.016 108.145 350.988 104.827 350.988 100.463C350.988 98.1898 350.687 96.2351 350.085 94.5986C349.483 92.9621 348.672 91.5983 347.654 90.5073C346.635 89.4163 345.477 88.6207 344.18 88.1207C342.884 87.6206 341.587 87.3706 340.29 87.3706Z" fill="#062D56"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M642.628 136.08L680.309 173.782L699.513 154.567L699.506 154.561L737.917 116.134L700.236 78.4367L700.243 78.4334L681.404 59.5826L642.993 98.014L642.99 98.0104L681.401 59.5829L643.725 21.881L662.929 2.6652L700.605 40.3673L739.016 1.93511L757.855 20.7859L719.443 59.2176L757.121 96.918L795.533 58.4875L814.373 77.3382L775.959 115.768L813.643 153.471L794.439 172.687L756.756 134.984L718.348 173.415L756.031 211.119L736.827 230.335L699.144 192.63L660.74 231.065L641.901 212.214L680.306 173.78L642.625 136.083L642.628 136.08Z" fill="#07B4B9"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M604.77 59.7651L642.446 97.4664L680.856 59.035L699.696 77.8858L661.285 116.317L698.966 154.019L679.762 173.235L642.081 135.532L603.675 173.965L584.836 155.114L623.243 116.682L585.566 78.9809L604.77 59.7651Z" fill="#062D56"/>
</g>
<defs>
<clipPath id="clip0">
<rect width="815" height="233" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -1,13 +1,3 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220101_initial where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220101_initial :: Query
m20220101_initial =
[sql|
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
@@ -25,13 +15,13 @@ CREATE TABLE users (
active_user INTEGER NOT NULL DEFAULT 0, -- 1 for active user
FOREIGN KEY (user_id, local_display_name)
REFERENCES display_names (user_id, local_display_name)
ON DELETE CASCADE
ON DELETE RESTRICT
ON UPDATE CASCADE
DEFERRABLE INITIALLY DEFERRED
);
CREATE TABLE display_names (
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users,
local_display_name TEXT NOT NULL,
ldn_base TEXT NOT NULL,
ldn_suffix INTEGER NOT NULL DEFAULT 0,
@@ -41,15 +31,15 @@ CREATE TABLE display_names (
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,
contact_profile_id INTEGER REFERENCES contact_profiles, -- NULL if it's an incognito profile
user_id INTEGER NOT NULL REFERENCES users,
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')),
FOREIGN KEY (user_id, local_display_name)
REFERENCES display_names (user_id, local_display_name)
ON DELETE CASCADE
ON DELETE RESTRICT
ON UPDATE CASCADE,
UNIQUE (user_id, local_display_name),
UNIQUE (user_id, contact_profile_id)
@@ -59,7 +49,7 @@ 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,
user_id INTEGER NOT NULL REFERENCES users,
UNIQUE (user_id, probe)
);
@@ -67,7 +57,7 @@ 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,
user_id INTEGER NOT NULL REFERENCES users,
UNIQUE (sent_probe_id, contact_id)
);
@@ -76,7 +66,7 @@ CREATE TABLE received_probes (
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
user_id INTEGER NOT NULL REFERENCES users
);
CREATE TABLE known_servers(
@@ -84,7 +74,7 @@ CREATE TABLE known_servers(
host TEXT NOT NULL,
port TEXT NOT NULL,
key_hash BLOB,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users,
UNIQUE (user_id, host, port)
) WITHOUT ROWID;
@@ -97,38 +87,35 @@ CREATE TABLE group_profiles ( -- shared group profiles
CREATE TABLE groups (
group_id INTEGER PRIMARY KEY, -- local group ID
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users,
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, -- received
group_profile_id INTEGER REFERENCES group_profiles, -- shared group profile
inv_queue_info BLOB,
FOREIGN KEY (user_id, local_display_name)
REFERENCES display_names (user_id, local_display_name)
ON DELETE CASCADE
ON DELETE RESTRICT
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,
group_id INTEGER NOT NULL REFERENCES groups ON DELETE RESTRICT,
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,
invited_by INTEGER REFERENCES contacts (contact_id) ON DELETE RESTRICT, -- NULL for the members who joined before the current user and for the group creator
group_queue_info BLOB,
direct_queue_info BLOB,
user_id INTEGER NOT NULL REFERENCES users,
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,
contact_profile_id INTEGER NOT NULL REFERENCES contact_profiles ON DELETE RESTRICT,
contact_id INTEGER REFERENCES contacts ON DELETE RESTRICT,
FOREIGN KEY (user_id, local_display_name)
REFERENCES display_names (user_id, local_display_name)
ON DELETE CASCADE
ON DELETE RESTRICT
ON UPDATE CASCADE,
UNIQUE (group_id, member_id)
);
@@ -145,28 +132,28 @@ CREATE TABLE group_member_intros (
CREATE TABLE files (
file_id INTEGER PRIMARY KEY,
contact_id INTEGER REFERENCES contacts ON DELETE CASCADE,
group_id INTEGER REFERENCES groups ON DELETE CASCADE,
contact_id INTEGER REFERENCES contacts ON DELETE RESTRICT,
group_id INTEGER REFERENCES groups ON DELETE RESTRICT,
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
user_id INTEGER NOT NULL REFERENCES users
);
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_id INTEGER NOT NULL REFERENCES files ON DELETE RESTRICT,
connection_id INTEGER NOT NULL REFERENCES connections ON DELETE RESTRICT,
file_status TEXT NOT NULL, -- new, accepted, connected, completed
group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
group_member_id INTEGER REFERENCES group_members ON DELETE RESTRICT,
PRIMARY KEY (file_id, connection_id)
) WITHOUT ROWID;
CREATE TABLE rcv_files (
file_id INTEGER PRIMARY KEY REFERENCES files ON DELETE CASCADE,
file_id INTEGER PRIMARY KEY REFERENCES files ON DELETE RESTRICT,
file_status TEXT NOT NULL, -- new, accepted, connected, completed
group_member_id INTEGER REFERENCES group_members ON DELETE CASCADE,
group_member_id INTEGER REFERENCES group_members ON DELETE RESTRICT,
file_queue_info BLOB
);
@@ -181,7 +168,7 @@ CREATE TABLE snd_file_chunks (
) WITHOUT ROWID;
CREATE TABLE rcv_file_chunks (
file_id INTEGER NOT NULL REFERENCES rcv_files ON DELETE CASCADE,
file_id INTEGER NOT NULL REFERENCES rcv_files,
chunk_number INTEGER NOT NULL,
chunk_agent_msg_id INTEGER NOT NULL,
chunk_stored INTEGER NOT NULL DEFAULT 0, -- 0 (received), 1 (appended to file)
@@ -192,80 +179,100 @@ 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,
via_contact INTEGER REFERENCES contacts (contact_id),
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,
contact_id INTEGER REFERENCES contacts ON DELETE RESTRICT,
group_member_id INTEGER REFERENCES group_members ON DELETE RESTRICT,
snd_file_id INTEGER,
rcv_file_id INTEGER REFERENCES rcv_files (file_id) ON DELETE CASCADE,
rcv_file_id INTEGER REFERENCES rcv_files (file_id) ON DELETE RESTRICT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users,
FOREIGN KEY (snd_file_id, connection_id)
REFERENCES snd_files (file_id, connection_id)
ON DELETE CASCADE
ON DELETE RESTRICT
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,
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,
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)
);
-- all message events as received or sent, append only
-- maps to message deliveries as one-to-many for group messages
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
CREATE TABLE events ( -- messages received by the agent, append only
event_id INTEGER PRIMARY KEY,
agent_msg_id INTEGER NOT NULL, -- internal message ID
external_msg_id INTEGER NOT NULL, -- external message ID (sent or received)
agent_meta TEXT NOT NULL, -- JSON with timestamps etc. sent in MSG
connection_id INTEGER NOT NULL REFERENCES connections,
received INTEGER NOT NULL, -- 0 for received, 1 for sent
chat_event_id INTEGER,
continuation_of INTEGER, -- references chat_event_id, but can be incorrect
event_type TEXT NOT NULL, -- event type - see protocol/types.ts
event_encoding INTEGER NOT NULL, -- format of event_body: 0 - binary, 1 - text utf8, 2 - JSON (utf8)
content_type TEXT NOT NULL, -- content type - see protocol/types.ts
event_body BLOB, -- agent message body as sent
event_hash BLOB NOT NULL,
integrity TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- TODO ? agent_msg_id could be NOT NULL now that pending_group_messages are separate
-- message deliveries communicated with the agent, append only
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')), -- broker_ts for received, created_at for sent
UNIQUE (connection_id, agent_msg_id)
CREATE INDEX events_external_msg_id_index ON events (connection_id, external_msg_id);
CREATE TABLE event_body_parts (
event_body_part_id INTEGER PRIMARY KEY,
event_id REFERENCES events,
full_size INTEGER NOT NULL,
part_status TEXT, -- full, partial
content_type TEXT NOT NULL,
event_part BLOB
);
-- TODO recovery for received messages with "rcv_agent" status - acknowledge to agent
-- changes of message delivery status, append only
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')),
UNIQUE (msg_delivery_id, delivery_status)
CREATE TABLE contact_profile_events (
event_id INTEGER NOT NULL UNIQUE REFERENCES events,
contact_profile_id INTEGER NOT NULL REFERENCES contact_profiles
);
CREATE TABLE group_profile_events (
event_id INTEGER NOT NULL UNIQUE REFERENCES events,
group_profile_id INTEGER NOT NULL REFERENCES group_profiles
);
CREATE TABLE group_events (
event_id INTEGER NOT NULL UNIQUE REFERENCES events,
group_id INTEGER NOT NULL REFERENCES groups ON DELETE RESTRICT,
group_member_id INTEGER REFERENCES group_members -- NULL for current user
);
CREATE TABLE group_event_parents (
group_event_parent_id INTEGER PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES group_events (event_id),
parent_group_member_id INTEGER REFERENCES group_members (group_member_id), -- can be NULL if parent_member_id is incorrect
parent_member_id BLOB, -- shared member ID, unique per group
parent_event_id INTEGER REFERENCES events (event_id) ON DELETE CASCADE, -- this can be NULL if received event references another event that's not received yet
parent_chat_event_id INTEGER NOT NULL,
parent_event_hash BLOB NOT NULL
);
CREATE INDEX group_event_parents_parent_chat_event_id_index
ON group_event_parents (parent_member_id, parent_chat_event_id);
CREATE TABLE messages ( -- mutable messages presented to user
message_id INTEGER PRIMARY KEY,
contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE RESTRICT, -- 1 for sent messages
group_id INTEGER REFERENCES groups ON DELETE RESTRICT, -- NULL for direct messages
deleted INTEGER NOT NULL, -- 1 for deleted
msg_type TEXT NOT NULL,
content_type TEXT NOT NULL,
msg_text TEXT NOT NULL, -- textual representation
msg_props TEXT NOT NULL -- JSON
);
CREATE TABLE message_content (
message_content_id INTEGER PRIMARY KEY,
message_id INTEGER REFERENCES messages ON DELETE CASCADE,
content_type TEXT NOT NULL,
content_size INTEGER, -- full expected content size
content_status TEXT, -- empty, part, full
content BLOB NOT NULL
);
CREATE TABLE message_events (
event_id INTEGER NOT NULL UNIQUE REFERENCES events,
message_id INTEGER NOT NULL REFERENCES messages
);
|]
+10 -9
View File
@@ -1,20 +1,20 @@
name: simplex-chat
version: 1.1.0
version: 0.4.2
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
license: AGPL-3
author: simplex.chat
maintainer: chat@simplex.chat
copyright: 2020-22 simplex.chat
author: Evgeny Poberezkin
maintainer: evgeny@poberezkin.com
copyright: 2020 Evgeny Poberezkin
category: Web, System, Services, Cryptography
extra-source-files:
- README.md
dependencies:
- aeson == 2.0.*
- ansi-terminal >= 0.10 && < 0.12
- attoparsec == 0.14.*
- aeson == 1.5.*
- ansi-terminal == 0.10.*
- attoparsec == 0.13.*
- base >= 4.7 && < 5
- base64-bytestring >= 1.0 && < 1.3
- bytestring == 0.10.*
@@ -23,12 +23,13 @@ dependencies:
- cryptonite >= 0.27 && < 0.30
- directory == 1.3.*
- exceptions == 0.10.*
- file-embed == 0.0.14.*
- filepath == 1.4.*
- mtl == 2.2.*
- optparse-applicative >= 0.15 && < 0.17
- optparse-applicative == 0.15.*
- process == 1.6.*
- simple-logger == 0.1.*
- simplexmq >= 1.0 && < 1.1
- simplexmq == 0.4.*
- sqlite-simple == 0.4.*
- stm == 2.5.*
- terminal == 0.2.*
File diff suppressed because it is too large Load Diff
+87
View File
@@ -0,0 +1,87 @@
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DeriveAnyClass #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Controller where
import Control.Exception
import Control.Monad.Except
import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Crypto.Random (ChaChaDRG)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import Numeric.Natural
import Simplex.Chat.Notification
import Simplex.Chat.Store (StoreError)
import Simplex.Chat.Terminal
import Simplex.Chat.Types
import Simplex.Messaging.Agent (AgentClient)
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig)
import Simplex.Messaging.Agent.Protocol (AgentErrorType)
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore)
import System.IO (Handle)
import UnliftIO.STM
data ChatConfig = ChatConfig
{ agentConfig :: AgentConfig,
dbPoolSize :: Int,
tbqSize :: Natural,
fileChunkSize :: Integer
}
data ChatController = ChatController
{ currentUser :: TVar User,
smpAgent :: AgentClient,
chatTerminal :: ChatTerminal,
chatStore :: SQLiteStore,
idsDrg :: TVar ChaChaDRG,
inputQ :: TBQueue InputEvent,
notifyQ :: TBQueue Notification,
sendNotification :: Notification -> IO (),
chatLock :: TMVar (),
sndFiles :: TVar (Map Int64 Handle),
rcvFiles :: TVar (Map Int64 Handle),
config :: ChatConfig
}
data InputEvent = InputCommand String | InputControl Char
data ChatError
= ChatError ChatErrorType
| ChatErrorMessage String
| ChatErrorAgent AgentErrorType
| ChatErrorStore StoreError
deriving (Show, Exception)
data ChatErrorType
= CEGroupUserRole
| CEGroupContactRole ContactName
| CEGroupDuplicateMember ContactName
| CEGroupDuplicateMemberId
| CEGroupNotJoined GroupName
| CEGroupMemberNotActive
| CEGroupMemberUserRemoved
| CEGroupMemberNotFound ContactName
| CEGroupInternal String
| CEFileNotFound String
| CEFileAlreadyReceiving String
| CEFileAlreadyExists FilePath
| CEFileRead FilePath SomeException
| CEFileWrite FilePath SomeException
| CEFileSend Int64 AgentErrorType
| CEFileRcvChunk String
| CEFileInternal String
deriving (Show, Exception)
type ChatMonad m = (MonadUnliftIO m, MonadReader ChatController m, MonadError ChatError m)
setActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m ()
setActive to = asks (activeTo . chatTerminal) >>= atomically . (`writeTVar` to)
unsetActive :: (MonadUnliftIO m, MonadReader ChatController m) => ActiveTo -> m ()
unsetActive a = asks (activeTo . chatTerminal) >>= atomically . (`modifyTVar` unset)
where
unset a' = if a == a' then ActiveNone else a'
@@ -1,22 +1,17 @@
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Help
( chatWelcome,
chatHelpInfo,
( chatHelpInfo,
filesHelpInfo,
groupsHelpInfo,
myAddressHelpInfo,
markdownInfo,
)
where
import Data.List (intersperse)
import Data.Text (Text)
import qualified Data.Text as T
import Simplex.Chat.Markdown
import Simplex.Chat.Styled
import Simplex.Chat.Types (Profile (..), User (..))
import System.Console.ANSI.Types
highlight :: Text -> Markdown
@@ -31,44 +26,15 @@ indent = " "
listHighlight :: [Text] -> Markdown
listHighlight = mconcat . intersperse ", " . map highlight
chatWelcome :: User -> [StyledString]
chatWelcome user =
map
styleMarkdown
[ " __ __",
" ___ ___ __ __ ___ _ ___" <> "\\ \\ / /" <> " ___ _ _ _ _____",
" / __|_ _| \\/ | _ \\ | | __ " <> "\\ V /" <> " / __| || | /_\\_ _|",
" \\__ \\| || |\\/| | _/ |__| _|" <> " / . \\" <> "| (__| __ |/ _ \\| |",
" |___/___|_| |_|_| |____|___" <> "/_/ \\_\\" <> "\\___|_||_/_/ \\_\\_|",
"",
"Welcome " <> green userName <> "!",
"Thank you for installing SimpleX Chat!",
"",
"We have a couple of groups that you can join to play with SimpleX Chat:",
highlight "#termux" <> " (Android Termux 📱) - chatting about using SimpleX Chat on Android devices",
highlight "#music" <> " (Music 🎸) - favorite music of our team and users",
"",
"Connect to SimpleX Chat team to be added to these groups - type " <> highlight "/simplex",
"",
"Follow our updates:",
"> Reddit: https://www.reddit.com/r/SimpleXChat/",
"> Twitter: https://twitter.com/SimpleXChat",
"",
"Type " <> highlight "/help" <> " for usage info, " <> highlight "/welcome" <> " to show this message"
]
where
User {profile = Profile {displayName, fullName}} = user
userName = if T.null fullName then displayName else fullName
chatHelpInfo :: [StyledString]
chatHelpInfo =
map
styleMarkdown
[ highlight "Using SimpleX Chat",
[ highlight "Using SimpleX chat prototype",
"Follow these steps to set up a connection:",
"",
green "Step 1: " <> highlight "/connect" <> " - Alice adds a contact.",
indent <> "Alice should send the one-time invitation printed by the /connect command",
indent <> "Alice should send the invitation printed by the /add command",
indent <> "to her contact, Bob, out-of-band, via any trusted channel.",
"",
green "Step 2: " <> highlight "/connect <invitation>" <> " - Bob accepts the invitation.",
@@ -79,22 +45,22 @@ chatHelpInfo =
indent <> highlight "@bob Hello, Bob!" <> " - Alice messages Bob (assuming Bob has display name 'bob').",
indent <> highlight "@alice Hey, Alice!" <> " - Bob replies to Alice.",
"",
green "Send file: " <> highlight "/file bob ./photo.jpg",
green "To send file:",
indent <> highlight "/file bob ./photo.jpg" <> " - Alice sends file to Bob",
indent <> "File commands: " <> highlight "/help files",
"",
green "Create group: " <> highlight "/group team",
"",
green "Create your address: " <> highlight "/address",
green "To create group:",
indent <> highlight "/group team" <> " - create group #team",
indent <> "Group commands: " <> highlight "/help groups",
"",
green "Other commands:",
indent <> highlight "/help <topic> " <> " - help on: files, groups, address",
indent <> highlight "/profile " <> " - show / update user profile",
indent <> highlight "/profile " <> " - show user profile",
indent <> highlight "/profile <name> [<full_name>]" <> " - update user profile",
indent <> highlight "/delete <contact>" <> " - delete contact and all messages with them",
indent <> highlight "/contacts " <> " - list contacts",
indent <> highlight "/markdown " <> " - supported markdown syntax",
indent <> highlight "/version " <> " - SimpleX Chat version",
indent <> highlight "/markdown " <> " - show supported markdown syntax",
indent <> highlight "/quit " <> " - quit chat",
"",
"The commands may be abbreviated: " <> listHighlight ["/c", "/f", "/g", "/p", "/ad"] <> ", etc."
"The commands may be abbreviated to a single letter: " <> listHighlight ["/c", "/f", "/g", "/p", "/h"] <> ", etc."
]
filesHelpInfo :: [StyledString]
@@ -123,26 +89,9 @@ groupsHelpInfo =
indent <> highlight "/leave <group> " <> " - leave group",
indent <> highlight "/delete <group> " <> " - delete group",
indent <> highlight "/members <group> " <> " - list group members",
indent <> highlight "/groups " <> " - list groups",
indent <> highlight "#<group> <message> " <> " - send message to group",
"",
"The commands may be abbreviated: " <> listHighlight ["/g", "/a", "/j", "/rm", "/l", "/d", "/ms", "/gs"]
]
myAddressHelpInfo :: [StyledString]
myAddressHelpInfo =
map
styleMarkdown
[ green "Your contact address commands:",
indent <> highlight "/address " <> " - create your address",
indent <> highlight "/delete_address" <> " - delete your address (accepted contacts will remain connected)",
indent <> highlight "/show_address " <> " - show your address",
indent <> highlight "/accept <name> " <> " - accept contact request",
indent <> highlight "/reject <name> " <> " - reject contact request",
"",
"Please note: you can receive spam contact requests, but it's safe to delete the address!",
"",
"The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"]
"The commands may be abbreviated: " <> listHighlight ["/g", "/a", "/j", "/rm", "/l", "/d", "/ms"]
]
markdownInfo :: [StyledString]
@@ -1,18 +1,15 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Simplex.Chat.Terminal.Input where
module Simplex.Chat.Input where
import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Data.List (dropWhileEnd)
import qualified Data.Text as T
import Simplex.Chat
import Simplex.Chat.Controller
import Simplex.Chat.Terminal.Output
import Simplex.Chat.View
import Simplex.Chat.Terminal
import System.Exit (exitSuccess)
import System.Terminal hiding (insertChars)
import UnliftIO.STM
@@ -24,24 +21,16 @@ getKey =
Right (KeyEvent key ms) -> pure (key, ms)
_ -> getKey
runInputLoop :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m ()
runInputLoop ct = do
q <- asks inputQ
forever $ do
s <- atomically $ readTBQueue q
r <- execChatCommand s
liftIO . printToTerminal ct $ responseToView s r
runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m ()
runTerminalInput ct = do
cc <- ask
runTerminalInput :: (MonadUnliftIO m, MonadReader ChatController m) => m ()
runTerminalInput = do
ChatController {inputQ, chatTerminal = ct} <- ask
liftIO $
withChatTerm ct $ do
updateInput ct
receiveFromTTY cc ct
receiveFromTTY inputQ ct
receiveFromTTY :: MonadTerminal m => ChatController -> ChatTerminal -> m ()
receiveFromTTY ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, termState} =
receiveFromTTY :: MonadTerminal m => TBQueue InputEvent -> ChatTerminal -> m ()
receiveFromTTY inputQ ct@ChatTerminal {activeTo, termSize, termState} =
forever $ getKey >>= processKey >> withTermLock ct (updateInput ct)
where
processKey :: MonadTerminal m => (Key, Modifiers) -> m ()
@@ -56,7 +45,7 @@ receiveFromTTY ChatController {inputQ, activeTo} ct@ChatTerminal {termSize, term
ts <- readTVar termState
let s = inputString ts
writeTVar termState $ ts {inputString = "", inputPosition = 0, previousInput = s}
writeTBQueue inputQ s
writeTBQueue inputQ $ InputCommand s
updateTermState :: ActiveTo -> Int -> (Key, Modifiers) -> TerminalState -> TerminalState
updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition = p} = case key of
@@ -3,22 +3,23 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Simplex.Chat.Terminal.Notification (Notification (..), initializeNotifications) where
module Simplex.Chat.Notification (Notification (..), initializeNotifications) where
import Control.Exception
import Control.Monad (void)
import Data.List (isInfixOf)
import Data.Map (Map, fromList)
import qualified Data.Map as M
import Data.Maybe (fromMaybe, isJust)
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Simplex.Chat.Types
import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, getAppUserDataDirectory)
import System.Directory (createDirectoryIfMissing, doesFileExist, getAppUserDataDirectory)
import System.FilePath (combine)
import System.Info (os)
import System.Process (readCreateProcess, shell)
data Notification = Notification {title :: Text, text :: Text}
initializeNotifications :: IO (Notification -> IO ())
initializeNotifications =
hideException <$> case os of
@@ -26,25 +27,17 @@ initializeNotifications =
"mingw32" -> initWinNotify
"linux" ->
doesFileExist "/proc/sys/kernel/osrelease" >>= \case
False -> initLinuxNotify
False -> pure $ notify linuxScript
True -> do
v <- readFile "/proc/sys/kernel/osrelease"
if "Microsoft" `isInfixOf` v || "WSL" `isInfixOf` v
then initWslNotify
else initLinuxNotify
_ -> pure noNotifications
noNotifications :: Notification -> IO ()
noNotifications _ = pure ()
else pure $ notify linuxScript
_ -> pure . const $ pure ()
hideException :: (a -> IO ()) -> (a -> IO ())
hideException f a = f a `catch` \(_ :: SomeException) -> pure ()
initLinuxNotify :: IO (Notification -> IO ())
initLinuxNotify = do
found <- isJust <$> findExecutable "notify-send"
pure $ if found then notify linuxScript else noNotifications
notify :: (Notification -> Text) -> Notification -> IO ()
notify script notification =
void $ readCreateProcess (shell . T.unpack $ script notification) ""
+62
View File
@@ -0,0 +1,62 @@
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Options (getChatOpts, ChatOpts (..)) where
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Char8 as B
import Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as L
import Options.Applicative
import Simplex.Messaging.Agent.Protocol (SMPServer (..), smpServerP)
import Simplex.Messaging.Parsers (parseAll)
import System.FilePath (combine)
data ChatOpts = ChatOpts
{ dbFile :: String,
smpServers :: NonEmpty SMPServer
}
chatOpts :: FilePath -> Parser ChatOpts
chatOpts appDir =
ChatOpts
<$> strOption
( long "database"
<> short 'd'
<> metavar "DB_FILE"
<> help ("sqlite database file path (" <> defaultDbFilePath <> ")")
<> value defaultDbFilePath
)
<*> option
parseSMPServer
( long "server"
<> short 's'
<> metavar "SERVER"
<> help
( "SMP server(s) to use"
<> "\n(smp2.simplex.im,smp3.simplex.im)"
)
<> value
( L.fromList
[ "smp2.simplex.im#z5W2QLQ1Br3Yd6CoWg7bIq1bHdwK7Y8bEiEXBs/WfAg=", -- London, UK
"smp3.simplex.im#nxc7HnrnM8dOKgkMp008ub/9o9LXJlxlMrMpR+mfMQw=" -- Fremont, CA
]
)
)
where
defaultDbFilePath = combine appDir "simplex"
parseSMPServer :: ReadM (NonEmpty SMPServer)
parseSMPServer = eitherReader $ parseAll servers . B.pack
where
servers = L.fromList <$> smpServerP `A.sepBy1` A.char ','
getChatOpts :: FilePath -> IO ChatOpts
getChatOpts appDir = execParser opts
where
opts =
info
(chatOpts appDir <**> helper)
( fullDesc
<> header "Chat prototype using Simplex Messaging Protocol (SMP)"
<> progDesc "Start chat with DB_FILE file and use SERVER as SMP server"
)
+383
View File
@@ -0,0 +1,383 @@
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TupleSections #-}
module Simplex.Chat.Protocol where
import Control.Applicative (optional)
import Control.Monad ((<=<))
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import Data.Attoparsec.ByteString.Char8 (Parser)
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Int (Int64)
import Data.List (find)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Types
import Simplex.Chat.Util (safeDecodeUtf8)
import Simplex.Messaging.Agent.Protocol
import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Util (bshow)
data ChatDirection (p :: AParty) where
ReceivedDirectMessage :: Connection -> Maybe Contact -> ChatDirection 'Agent
SentDirectMessage :: Contact -> ChatDirection 'Client
ReceivedGroupMessage :: Connection -> GroupName -> GroupMember -> ChatDirection 'Agent
SentGroupMessage :: GroupName -> ChatDirection 'Client
SndFileConnection :: Connection -> SndFileTransfer -> ChatDirection 'Agent
RcvFileConnection :: Connection -> RcvFileTransfer -> ChatDirection 'Agent
deriving instance Eq (ChatDirection p)
deriving instance Show (ChatDirection p)
fromConnection :: ChatDirection 'Agent -> Connection
fromConnection = \case
ReceivedDirectMessage conn _ -> conn
ReceivedGroupMessage conn _ _ -> conn
SndFileConnection conn _ -> conn
RcvFileConnection conn _ -> conn
data ChatMsgEvent
= XMsgNew MsgContent
| XFile FileInvitation
| XFileAcpt String
| XInfo Profile
| XGrpInv GroupInvitation
| XGrpAcpt MemberId
| XGrpMemNew MemberInfo
| XGrpMemIntro MemberInfo
| XGrpMemInv MemberId IntroInvitation
| XGrpMemFwd MemberInfo IntroInvitation
| XGrpMemInfo MemberId Profile
| XGrpMemCon MemberId
| XGrpMemConAll MemberId
| XGrpMemDel MemberId
| XGrpLeave
| XGrpDel
| XInfoProbe ByteString
| XInfoProbeCheck ByteString
| XInfoProbeOk ByteString
| XOk
deriving (Eq, Show)
data MessageType = MTText | MTImage deriving (Eq, Show)
data MsgContent = MsgContent
{ messageType :: MessageType,
files :: [(ContentType, Int)],
content :: [MsgContentBody]
}
deriving (Eq, Show)
toMsgType :: ByteString -> Either String MessageType
toMsgType = \case
"c.text" -> Right MTText
"c.image" -> Right MTImage
t -> Left $ "invalid message type " <> B.unpack t
rawMsgType :: MessageType -> ByteString
rawMsgType = \case
MTText -> "c.text"
MTImage -> "c.image"
data ChatMessage = ChatMessage
{ chatMsgId :: Maybe Int64,
chatMsgEvent :: ChatMsgEvent,
chatDAG :: Maybe ByteString
}
deriving (Eq, Show)
toChatMessage :: RawChatMessage -> Either String ChatMessage
toChatMessage RawChatMessage {chatMsgId, chatMsgEvent, chatMsgParams, chatMsgBody} = do
(chatDAG, body) <- getDAG <$> mapM toMsgBodyContent chatMsgBody
let chatMsg msg = pure ChatMessage {chatMsgId, chatMsgEvent = msg, chatDAG}
case (chatMsgEvent, chatMsgParams) of
("x.msg.new", mt : rawFiles) -> do
t <- toMsgType mt
files <- mapM (toContentInfo <=< parseAll contentInfoP) rawFiles
chatMsg . XMsgNew $ MsgContent {messageType = t, files, content = body}
("x.file", [name, size, qInfo]) -> do
let fileName = T.unpack $ safeDecodeUtf8 name
fileSize <- parseAll A.decimal size
fileQInfo <- parseAll smpQueueInfoP qInfo
chatMsg . XFile $ FileInvitation {fileName, fileSize, fileQInfo}
("x.file.acpt", [name]) ->
chatMsg . XFileAcpt . T.unpack $ safeDecodeUtf8 name
("x.info", []) -> do
profile <- getJSON body
chatMsg $ XInfo profile
("x.grp.inv", [fromMemId, fromRole, memId, role, qInfo]) -> do
fromMem <- (,) <$> B64.decode fromMemId <*> toMemberRole fromRole
invitedMem <- (,) <$> B64.decode memId <*> toMemberRole role
groupQInfo <- parseAll smpQueueInfoP qInfo
profile <- getJSON body
chatMsg . XGrpInv $ GroupInvitation fromMem invitedMem groupQInfo profile
("x.grp.acpt", [memId]) ->
chatMsg . XGrpAcpt =<< B64.decode memId
("x.grp.mem.new", [memId, role]) -> do
chatMsg . XGrpMemNew =<< toMemberInfo memId role body
("x.grp.mem.intro", [memId, role]) ->
chatMsg . XGrpMemIntro =<< toMemberInfo memId role body
("x.grp.mem.inv", [memId, groupQInfo, directQInfo]) ->
chatMsg =<< (XGrpMemInv <$> B64.decode memId <*> toIntroInv groupQInfo directQInfo)
("x.grp.mem.fwd", [memId, role, groupQInfo, directQInfo]) -> do
chatMsg =<< (XGrpMemFwd <$> toMemberInfo memId role body <*> toIntroInv groupQInfo directQInfo)
("x.grp.mem.info", [memId]) ->
chatMsg =<< (XGrpMemInfo <$> B64.decode memId <*> getJSON body)
("x.grp.mem.con", [memId]) ->
chatMsg . XGrpMemCon =<< B64.decode memId
("x.grp.mem.con.all", [memId]) ->
chatMsg . XGrpMemConAll =<< B64.decode memId
("x.grp.mem.del", [memId]) ->
chatMsg . XGrpMemDel =<< B64.decode memId
("x.grp.leave", []) ->
chatMsg XGrpLeave
("x.grp.del", []) ->
chatMsg XGrpDel
("x.info.probe", [probe]) -> do
chatMsg . XInfoProbe =<< B64.decode probe
("x.info.probe.check", [probeHash]) -> do
chatMsg =<< (XInfoProbeCheck <$> B64.decode probeHash)
("x.info.probe.ok", [probe]) -> do
chatMsg =<< (XInfoProbeOk <$> B64.decode probe)
("x.ok", []) ->
chatMsg XOk
_ -> Left $ "bad syntax or unsupported event " <> B.unpack chatMsgEvent
where
getDAG :: [MsgContentBody] -> (Maybe ByteString, [MsgContentBody])
getDAG body = case break (isContentType SimplexDAG) body of
(b, MsgContentBody SimplexDAG dag : a) -> (Just dag, b <> a)
_ -> (Nothing, body)
toMemberInfo :: ByteString -> ByteString -> [MsgContentBody] -> Either String MemberInfo
toMemberInfo memId role body = MemberInfo <$> B64.decode memId <*> toMemberRole role <*> getJSON body
toIntroInv :: ByteString -> ByteString -> Either String IntroInvitation
toIntroInv groupQInfo directQInfo = IntroInvitation <$> parseAll smpQueueInfoP groupQInfo <*> parseAll smpQueueInfoP directQInfo
toContentInfo :: (RawContentType, Int) -> Either String (ContentType, Int)
toContentInfo (rawType, size) = (,size) <$> toContentType rawType
getJSON :: FromJSON a => [MsgContentBody] -> Either String a
getJSON = J.eitherDecodeStrict' <=< getSimplexContentType XCJson
isContentType :: ContentType -> MsgContentBody -> Bool
isContentType t MsgContentBody {contentType = t'} = t == t'
isSimplexContentType :: XContentType -> MsgContentBody -> Bool
isSimplexContentType = isContentType . SimplexContentType
getContentType :: ContentType -> [MsgContentBody] -> Either String ByteString
getContentType t body = case find (isContentType t) body of
Just MsgContentBody {contentData} -> Right contentData
Nothing -> Left "no required content type"
getSimplexContentType :: XContentType -> [MsgContentBody] -> Either String ByteString
getSimplexContentType = getContentType . SimplexContentType
rawChatMessage :: ChatMessage -> RawChatMessage
rawChatMessage ChatMessage {chatMsgId, chatMsgEvent, chatDAG} =
case chatMsgEvent of
XMsgNew MsgContent {messageType = t, files, content} ->
let rawFiles = map (serializeContentInfo . rawContentInfo) files
in rawMsg "x.msg.new" (rawMsgType t : rawFiles) content
XFile FileInvitation {fileName, fileSize, fileQInfo} ->
rawMsg "x.file" [encodeUtf8 $ T.pack fileName, bshow fileSize, serializeSmpQueueInfo fileQInfo] []
XFileAcpt fileName ->
rawMsg "x.file.acpt" [encodeUtf8 $ T.pack fileName] []
XInfo profile ->
rawMsg "x.info" [] [jsonBody profile]
XGrpInv (GroupInvitation (fromMemId, fromRole) (memId, role) qInfo groupProfile) ->
let params =
[ B64.encode fromMemId,
serializeMemberRole fromRole,
B64.encode memId,
serializeMemberRole role,
serializeSmpQueueInfo qInfo
]
in rawMsg "x.grp.inv" params [jsonBody groupProfile]
XGrpAcpt memId ->
rawMsg "x.grp.acpt" [B64.encode memId] []
XGrpMemNew (MemberInfo memId role profile) ->
let params = [B64.encode memId, serializeMemberRole role]
in rawMsg "x.grp.mem.new" params [jsonBody profile]
XGrpMemIntro (MemberInfo memId role profile) ->
rawMsg "x.grp.mem.intro" [B64.encode memId, serializeMemberRole role] [jsonBody profile]
XGrpMemInv memId IntroInvitation {groupQInfo, directQInfo} ->
let params = [B64.encode memId, serializeSmpQueueInfo groupQInfo, serializeSmpQueueInfo directQInfo]
in rawMsg "x.grp.mem.inv" params []
XGrpMemFwd (MemberInfo memId role profile) IntroInvitation {groupQInfo, directQInfo} ->
let params =
[ B64.encode memId,
serializeMemberRole role,
serializeSmpQueueInfo groupQInfo,
serializeSmpQueueInfo directQInfo
]
in rawMsg "x.grp.mem.fwd" params [jsonBody profile]
XGrpMemInfo memId profile ->
rawMsg "x.grp.mem.info" [B64.encode memId] [jsonBody profile]
XGrpMemCon memId ->
rawMsg "x.grp.mem.con" [B64.encode memId] []
XGrpMemConAll memId ->
rawMsg "x.grp.mem.con.all" [B64.encode memId] []
XGrpMemDel memId ->
rawMsg "x.grp.mem.del" [B64.encode memId] []
XGrpLeave ->
rawMsg "x.grp.leave" [] []
XGrpDel ->
rawMsg "x.grp.del" [] []
XInfoProbe probe ->
rawMsg "x.info.probe" [B64.encode probe] []
XInfoProbeCheck probeHash ->
rawMsg "x.info.probe.check" [B64.encode probeHash] []
XInfoProbeOk probe ->
rawMsg "x.info.probe.ok" [B64.encode probe] []
XOk ->
rawMsg "x.ok" [] []
where
rawMsg :: ByteString -> [ByteString] -> [MsgContentBody] -> RawChatMessage
rawMsg event chatMsgParams body =
RawChatMessage {chatMsgId, chatMsgEvent = event, chatMsgParams, chatMsgBody = rawWithDAG body}
rawContentInfo :: (ContentType, Int) -> (RawContentType, Int)
rawContentInfo (t, size) = (rawContentType t, size)
jsonBody :: ToJSON a => a -> MsgContentBody
jsonBody x =
let json = LB.toStrict $ J.encode x
in MsgContentBody {contentType = SimplexContentType XCJson, contentData = json}
rawWithDAG :: [MsgContentBody] -> [RawMsgBodyContent]
rawWithDAG body = map rawMsgBodyContent $ case chatDAG of
Nothing -> body
Just dag -> MsgContentBody {contentType = SimplexDAG, contentData = dag} : body
toMsgBodyContent :: RawMsgBodyContent -> Either String MsgContentBody
toMsgBodyContent RawMsgBodyContent {contentType, contentData} = do
cType <- toContentType contentType
pure MsgContentBody {contentType = cType, contentData}
rawMsgBodyContent :: MsgContentBody -> RawMsgBodyContent
rawMsgBodyContent MsgContentBody {contentType = t, contentData} =
RawMsgBodyContent {contentType = rawContentType t, contentData}
data MsgContentBody = MsgContentBody
{ contentType :: ContentType,
contentData :: ByteString
}
deriving (Eq, Show)
data ContentType
= SimplexContentType XContentType
| MimeContentType MContentType
| SimplexDAG
deriving (Eq, Show)
data XContentType = XCText | XCImage | XCJson deriving (Eq, Show)
data MContentType = MCImageJPG | MCImagePNG deriving (Eq, Show)
toContentType :: RawContentType -> Either String ContentType
toContentType (RawContentType ns cType) = case ns of
"x" -> case cType of
"text" -> Right $ SimplexContentType XCText
"image" -> Right $ SimplexContentType XCImage
"json" -> Right $ SimplexContentType XCJson
"dag" -> Right SimplexDAG
_ -> err
"m" -> case cType of
"image/jpg" -> Right $ MimeContentType MCImageJPG
"image/png" -> Right $ MimeContentType MCImagePNG
_ -> err
_ -> err
where
err = Left . B.unpack $ "invalid content type " <> ns <> "." <> cType
rawContentType :: ContentType -> RawContentType
rawContentType t = case t of
SimplexContentType t' -> RawContentType "x" $ case t' of
XCText -> "text"
XCImage -> "image"
XCJson -> "json"
MimeContentType t' -> RawContentType "m" $ case t' of
MCImageJPG -> "image/jpg"
MCImagePNG -> "image/png"
SimplexDAG -> RawContentType "x" "dag"
newtype ContentMsg = NewContentMsg ContentData
newtype ContentData = ContentText Text
data RawChatMessage = RawChatMessage
{ chatMsgId :: Maybe Int64,
chatMsgEvent :: ByteString,
chatMsgParams :: [ByteString],
chatMsgBody :: [RawMsgBodyContent]
}
deriving (Eq, Show)
data RawMsgBodyContent = RawMsgBodyContent
{ contentType :: RawContentType,
contentData :: ByteString
}
deriving (Eq, Show)
data RawContentType = RawContentType NameSpace ByteString
deriving (Eq, Show)
type NameSpace = ByteString
newtype MsgData = MsgData ByteString
deriving (Eq, Show)
class DataLength a where
dataLength :: a -> Int
rawChatMessageP :: Parser RawChatMessage
rawChatMessageP = do
chatMsgId <- optional A.decimal <* A.space
chatMsgEvent <- B.intercalate "." <$> identifierP `A.sepBy1'` A.char '.' <* A.space
chatMsgParams <- A.takeWhile1 (not . A.inClass ", ") `A.sepBy'` A.char ',' <* A.space
chatMsgBody <- msgBodyContent =<< contentInfoP `A.sepBy'` A.char ',' <* A.space
pure RawChatMessage {chatMsgId, chatMsgEvent, chatMsgParams, chatMsgBody}
where
msgBodyContent :: [(RawContentType, Int)] -> Parser [RawMsgBodyContent]
msgBodyContent [] = pure []
msgBodyContent ((contentType, size) : ps) = do
contentData <- A.take size <* A.space
((RawMsgBodyContent {contentType, contentData}) :) <$> msgBodyContent ps
contentInfoP :: Parser (RawContentType, Int)
contentInfoP = do
contentType <- RawContentType <$> identifierP <* A.char '.' <*> A.takeTill (A.inClass ":, ")
size <- A.char ':' *> A.decimal
pure (contentType, size)
identifierP :: Parser ByteString
identifierP = B.cons <$> A.letter_ascii <*> A.takeWhile (\c -> A.isAlpha_ascii c || A.isDigit c)
serializeRawChatMessage :: RawChatMessage -> ByteString
serializeRawChatMessage RawChatMessage {chatMsgId, chatMsgEvent, chatMsgParams, chatMsgBody} =
B.unwords
[ maybe "" bshow chatMsgId,
chatMsgEvent,
B.intercalate "," chatMsgParams,
B.unwords $ map serializeBodyContentInfo chatMsgBody,
B.unwords $ map msgContentData chatMsgBody
]
serializeBodyContentInfo :: RawMsgBodyContent -> ByteString
serializeBodyContentInfo RawMsgBodyContent {contentType = t, contentData} =
serializeContentInfo (t, B.length contentData)
serializeContentInfo :: (RawContentType, Int) -> ByteString
serializeContentInfo (RawContentType ns cType, size) = ns <> "." <> cType <> ":" <> bshow size
msgContentData :: RawMsgBodyContent -> ByteString
msgContentData RawMsgBodyContent {contentData} = contentData <> " "
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,6 @@ module Simplex.Chat.Styled
StyledFormat (..),
styleMarkdown,
styleMarkdownText,
unStyle,
sLength,
sShow,
)
@@ -21,7 +20,6 @@ import Simplex.Chat.Markdown
import System.Console.ANSI.Types
data StyledString = Styled [SGR] String | StyledString :<>: StyledString
deriving (Show)
instance Semigroup StyledString where (<>) = (:<>:)
@@ -71,10 +69,6 @@ sgr = \case
Snippet -> []
NoFormat -> []
unStyle :: StyledString -> String
unStyle (Styled _ s) = s
unStyle (s1 :<>: s2) = unStyle s1 <> unStyle s2
sLength :: StyledString -> Int
sLength (Styled _ s) = length s
sLength (s1 :<>: s2) = sLength s1 + sLength s2
@@ -1,25 +1,26 @@
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Simplex.Chat.Terminal.Output where
module Simplex.Chat.Terminal where
import Control.Monad.Catch (MonadMask)
import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Simplex.Chat.Controller
import Control.Monad.IO.Class (MonadIO)
import Simplex.Chat.Styled
import Simplex.Chat.View
import Simplex.Chat.Types
import System.Console.ANSI.Types
import System.Terminal
import System.Terminal.Internal (LocalTerminal, Terminal, VirtualTerminal)
import UnliftIO.STM
data ActiveTo = ActiveNone | ActiveC ContactName | ActiveG GroupName
deriving (Eq)
data ChatTerminal = ChatTerminal
{ termDevice :: TerminalDevice,
{ activeTo :: TVar ActiveTo,
termDevice :: TerminalDevice,
termState :: TVar TerminalState,
termSize :: Size,
nextMessageRow :: TVar Int,
@@ -49,16 +50,17 @@ withChatTerm ChatTerminal {termDevice = TerminalDevice t} action = withTerm t $
newChatTerminal :: WithTerminal t => t -> IO ChatTerminal
newChatTerminal t = do
activeTo <- newTVarIO ActiveNone
termSize <- withTerm t . runTerminalT $ getWindowSize
let lastRow = height termSize - 1
termState <- newTVarIO mkTermState
termState <- newTVarIO newTermState
termLock <- newTMVarIO ()
nextMessageRow <- newTVarIO lastRow
-- threadDelay 500000 -- this delay is the same as timeout in getTerminalSize
return ChatTerminal {termDevice = TerminalDevice t, termState, termSize, nextMessageRow, termLock}
return ChatTerminal {activeTo, termDevice = TerminalDevice t, termState, termSize, nextMessageRow, termLock}
mkTermState :: TerminalState
mkTermState =
newTermState :: TerminalState
newTermState =
TerminalState
{ inputString = "",
inputPosition = 0,
@@ -72,12 +74,6 @@ withTermLock ChatTerminal {termLock} action = do
action
atomically $ putTMVar termLock ()
runTerminalOutput :: (MonadUnliftIO m, MonadReader ChatController m) => ChatTerminal -> m ()
runTerminalOutput ct = do
ChatController {outputQ} <- ask
forever $
atomically (readTBQueue outputQ) >>= liftIO . printToTerminal ct . responseToView "" . snd
printToTerminal :: ChatTerminal -> [StyledString] -> IO ()
printToTerminal ct s =
withChatTerm ct $
+497
View File
@@ -0,0 +1,497 @@
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.Types where
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Int (Int64)
import Data.Text (Text)
import Data.Time.Clock (UTCTime)
import Data.Typeable (Typeable)
import Database.SQLite.Simple (ResultError (..), SQLData (..))
import Database.SQLite.Simple.FromField (FieldParser, FromField (..), returnError)
import Database.SQLite.Simple.Internal (Field (..))
import Database.SQLite.Simple.Ok (Ok (Ok))
import Database.SQLite.Simple.ToField (ToField (..))
import GHC.Generics
import Simplex.Messaging.Agent.Protocol (ConnId, SMPQueueInfo)
import Simplex.Messaging.Agent.Store.SQLite (fromTextField_)
class IsContact a where
contactId' :: a -> Int64
profile' :: a -> Profile
localDisplayName' :: a -> ContactName
instance IsContact User where
contactId' = userContactId
profile' = profile
localDisplayName' = localDisplayName
instance IsContact Contact where
contactId' = contactId
profile' = profile
localDisplayName' = localDisplayName
data User = User
{ userId :: UserId,
userContactId :: Int64,
localDisplayName :: ContactName,
profile :: Profile,
activeUser :: Bool
}
type UserId = Int64
data Contact = Contact
{ contactId :: Int64,
localDisplayName :: ContactName,
profile :: Profile,
activeConn :: Connection,
viaGroup :: Maybe Int64
}
deriving (Eq, Show)
contactConnId :: Contact -> ConnId
contactConnId Contact {activeConn = Connection {agentConnId}} = agentConnId
type ContactName = Text
type GroupName = Text
data Group = Group
{ groupId :: Int64,
localDisplayName :: GroupName,
groupProfile :: GroupProfile,
members :: [GroupMember],
membership :: GroupMember
}
deriving (Eq, Show)
data Profile = Profile
{ displayName :: ContactName,
fullName :: Text
}
deriving (Generic, Eq, Show)
instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions
instance FromJSON Profile
data GroupProfile = GroupProfile
{ displayName :: GroupName,
fullName :: Text
}
deriving (Generic, Eq, Show)
instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions
instance FromJSON GroupProfile
data GroupInvitation = GroupInvitation
{ fromMember :: (MemberId, GroupMemberRole),
invitedMember :: (MemberId, GroupMemberRole),
queueInfo :: SMPQueueInfo,
groupProfile :: GroupProfile
}
deriving (Eq, Show)
data IntroInvitation = IntroInvitation
{ groupQInfo :: SMPQueueInfo,
directQInfo :: SMPQueueInfo
}
deriving (Eq, Show)
data MemberInfo = MemberInfo MemberId GroupMemberRole Profile
deriving (Eq, Show)
memberInfo :: GroupMember -> MemberInfo
memberInfo m = MemberInfo (memberId m) (memberRole m) (memberProfile m)
data ReceivedGroupInvitation = ReceivedGroupInvitation
{ fromMember :: GroupMember,
userMember :: GroupMember,
queueInfo :: SMPQueueInfo,
groupProfile :: GroupProfile
}
deriving (Eq, Show)
data GroupMember = GroupMember
{ groupMemberId :: Int64,
groupId :: Int64,
memberId :: MemberId,
memberRole :: GroupMemberRole,
memberCategory :: GroupMemberCategory,
memberStatus :: GroupMemberStatus,
invitedBy :: InvitedBy,
localDisplayName :: ContactName,
memberProfile :: Profile,
memberContactId :: Maybe Int64,
activeConn :: Maybe Connection
}
deriving (Eq, Show)
memberConnId :: GroupMember -> Maybe ConnId
memberConnId GroupMember {activeConn} = case activeConn of
Just Connection {agentConnId} -> Just agentConnId
Nothing -> Nothing
data NewGroupMember = NewGroupMember
{ memInfo :: MemberInfo,
memCategory :: GroupMemberCategory,
memStatus :: GroupMemberStatus,
memInvitedBy :: InvitedBy,
localDisplayName :: ContactName,
memProfileId :: Int64,
memContactId :: Maybe Int64
}
type MemberId = ByteString
data InvitedBy = IBContact Int64 | IBUser | IBUnknown
deriving (Eq, Show)
toInvitedBy :: Int64 -> Maybe Int64 -> InvitedBy
toInvitedBy userCtId (Just ctId)
| userCtId == ctId = IBUser
| otherwise = IBContact ctId
toInvitedBy _ Nothing = IBUnknown
fromInvitedBy :: Int64 -> InvitedBy -> Maybe Int64
fromInvitedBy userCtId = \case
IBUnknown -> Nothing
IBContact ctId -> Just ctId
IBUser -> Just userCtId
data GroupMemberRole = GRMember | GRAdmin | GROwner
deriving (Eq, Show, Ord)
instance FromField GroupMemberRole where fromField = fromBlobField_ toMemberRole
instance ToField GroupMemberRole where toField = toField . serializeMemberRole
toMemberRole :: ByteString -> Either String GroupMemberRole
toMemberRole = \case
"owner" -> Right GROwner
"admin" -> Right GRAdmin
"member" -> Right GRMember
r -> Left $ "invalid group member role " <> B.unpack r
serializeMemberRole :: GroupMemberRole -> ByteString
serializeMemberRole = \case
GROwner -> "owner"
GRAdmin -> "admin"
GRMember -> "member"
fromBlobField_ :: Typeable k => (ByteString -> Either String k) -> FieldParser k
fromBlobField_ p = \case
f@(Field (SQLBlob b) _) ->
case p b of
Right k -> Ok k
Left e -> returnError ConversionFailed f ("could not parse field: " ++ e)
f -> returnError ConversionFailed f "expecting SQLBlob column type"
data GroupMemberCategory
= GCUserMember
| GCInviteeMember -- member invited by the user
| GCHostMember -- member who invited the user
| GCPreMember -- member who joined before the user and was introduced to the user (user receives x.grp.mem.intro about such members)
| GCPostMember -- member who joined after the user to whom the user was introduced (user receives x.grp.mem.new announcing these members and then x.grp.mem.fwd with invitation from these members)
deriving (Eq, Show)
instance FromField GroupMemberCategory where fromField = fromTextField_ memberCategoryT
instance ToField GroupMemberCategory where toField = toField . serializeMemberCategory
memberCategoryT :: Text -> Maybe GroupMemberCategory
memberCategoryT = \case
"user" -> Just GCUserMember
"invitee" -> Just GCInviteeMember
"host" -> Just GCHostMember
"pre" -> Just GCPreMember
"post" -> Just GCPostMember
_ -> Nothing
serializeMemberCategory :: GroupMemberCategory -> Text
serializeMemberCategory = \case
GCUserMember -> "user"
GCInviteeMember -> "invitee"
GCHostMember -> "host"
GCPreMember -> "pre"
GCPostMember -> "post"
data GroupMemberStatus
= GSMemRemoved -- member who was removed from the group
| GSMemLeft -- member who left the group
| GSMemGroupDeleted -- user member of the deleted group
| GSMemInvited -- member is sent to or received invitation to join the group
| GSMemIntroduced -- user received x.grp.mem.intro for this member (only with GCPreMember)
| GSMemIntroInvited -- member is sent to or received from intro invitation
| GSMemAccepted -- member accepted invitation (only User and Invitee)
| GSMemAnnounced -- host announced (x.grp.mem.new) a member (Invitee and PostMember) to the group - at this point this member can send messages and invite other members (if they have sufficient permissions)
| GSMemConnected -- member created the group connection with the inviting member
| GSMemComplete -- host confirmed (x.grp.mem.all) that a member (User, Invitee and PostMember) created group connections with all previous members
| GSMemCreator -- user member that created the group (only GCUserMember)
deriving (Eq, Show, Ord)
instance FromField GroupMemberStatus where fromField = fromTextField_ memberStatusT
instance ToField GroupMemberStatus where toField = toField . serializeMemberStatus
memberActive :: GroupMember -> Bool
memberActive m = case memberStatus m of
GSMemRemoved -> False
GSMemLeft -> False
GSMemGroupDeleted -> False
GSMemInvited -> False
GSMemIntroduced -> False
GSMemIntroInvited -> False
GSMemAccepted -> False
GSMemAnnounced -> False
GSMemConnected -> True
GSMemComplete -> True
GSMemCreator -> True
memberCurrent :: GroupMember -> Bool
memberCurrent m = case memberStatus m of
GSMemRemoved -> False
GSMemLeft -> False
GSMemGroupDeleted -> False
GSMemInvited -> False
GSMemIntroduced -> True
GSMemIntroInvited -> True
GSMemAccepted -> True
GSMemAnnounced -> True
GSMemConnected -> True
GSMemComplete -> True
GSMemCreator -> True
memberStatusT :: Text -> Maybe GroupMemberStatus
memberStatusT = \case
"removed" -> Just GSMemRemoved
"left" -> Just GSMemLeft
"deleted" -> Just GSMemGroupDeleted
"invited" -> Just GSMemInvited
"introduced" -> Just GSMemIntroduced
"intro-inv" -> Just GSMemIntroInvited
"accepted" -> Just GSMemAccepted
"announced" -> Just GSMemAnnounced
"connected" -> Just GSMemConnected
"complete" -> Just GSMemComplete
"creator" -> Just GSMemCreator
_ -> Nothing
serializeMemberStatus :: GroupMemberStatus -> Text
serializeMemberStatus = \case
GSMemRemoved -> "removed"
GSMemLeft -> "left"
GSMemGroupDeleted -> "deleted"
GSMemInvited -> "invited"
GSMemIntroduced -> "introduced"
GSMemIntroInvited -> "intro-inv"
GSMemAccepted -> "accepted"
GSMemAnnounced -> "announced"
GSMemConnected -> "connected"
GSMemComplete -> "complete"
GSMemCreator -> "creator"
data SndFileTransfer = SndFileTransfer
{ fileId :: Int64,
fileName :: String,
filePath :: String,
fileSize :: Integer,
chunkSize :: Integer,
recipientDisplayName :: ContactName,
connId :: Int64,
agentConnId :: ConnId,
fileStatus :: FileStatus
}
deriving (Eq, Show)
data FileInvitation = FileInvitation
{ fileName :: String,
fileSize :: Integer,
fileQInfo :: SMPQueueInfo
}
deriving (Eq, Show)
data RcvFileTransfer = RcvFileTransfer
{ fileId :: Int64,
fileInvitation :: FileInvitation,
fileStatus :: RcvFileStatus,
senderDisplayName :: ContactName,
chunkSize :: Integer
}
deriving (Eq, Show)
data RcvFileStatus
= RFSNew
| RFSAccepted RcvFileInfo
| RFSConnected RcvFileInfo
| RFSComplete RcvFileInfo
| RFSCancelled RcvFileInfo
deriving (Eq, Show)
data RcvFileInfo = RcvFileInfo
{ filePath :: FilePath,
connId :: Int64,
agentConnId :: ConnId
}
deriving (Eq, Show)
data FileTransfer = FTSnd [SndFileTransfer] | FTRcv RcvFileTransfer
data FileStatus = FSNew | FSAccepted | FSConnected | FSComplete | FSCancelled deriving (Eq, Ord, Show)
instance FromField FileStatus where fromField = fromTextField_ fileStatusT
instance ToField FileStatus where toField = toField . serializeFileStatus
fileStatusT :: Text -> Maybe FileStatus
fileStatusT = \case
"new" -> Just FSNew
"accepted" -> Just FSAccepted
"connected" -> Just FSConnected
"complete" -> Just FSComplete
"cancelled" -> Just FSCancelled
_ -> Nothing
serializeFileStatus :: FileStatus -> Text
serializeFileStatus = \case
FSNew -> "new"
FSAccepted -> "accepted"
FSConnected -> "connected"
FSComplete -> "complete"
FSCancelled -> "cancelled"
data RcvChunkStatus = RcvChunkOk | RcvChunkFinal | RcvChunkDuplicate | RcvChunkError
deriving (Eq, Show)
data Connection = Connection
{ connId :: Int64,
agentConnId :: ConnId,
connLevel :: Int,
viaContact :: Maybe Int64,
connType :: ConnType,
connStatus :: ConnStatus,
entityId :: Maybe Int64, -- contact, group member or file ID
createdAt :: UTCTime
}
deriving (Eq, Show)
data ConnStatus
= -- | connection is created by initiating party with agent NEW command (createConnection)
ConnNew
| -- | connection is joined by joining party with agent JOIN command (joinConnection)
ConnJoined
| -- | initiating party received CONF notification (to be renamed to REQ)
ConnRequested
| -- | initiating party accepted connection with agent LET command (to be renamed to ACPT) (allowConnection)
ConnAccepted
| -- | connection can be sent messages to (after joining party received INFO notification)
ConnSndReady
| -- | connection is ready for both parties to send and receive messages
ConnReady
| -- | connection deleted
ConnDeleted
deriving (Eq, Show)
instance FromField ConnStatus where fromField = fromTextField_ connStatusT
instance ToField ConnStatus where toField = toField . serializeConnStatus
connStatusT :: Text -> Maybe ConnStatus
connStatusT = \case
"new" -> Just ConnNew
"joined" -> Just ConnJoined
"requested" -> Just ConnRequested
"accepted" -> Just ConnAccepted
"snd-ready" -> Just ConnSndReady
"ready" -> Just ConnReady
"deleted" -> Just ConnDeleted
_ -> Nothing
serializeConnStatus :: ConnStatus -> Text
serializeConnStatus = \case
ConnNew -> "new"
ConnJoined -> "joined"
ConnRequested -> "requested"
ConnAccepted -> "accepted"
ConnSndReady -> "snd-ready"
ConnReady -> "ready"
ConnDeleted -> "deleted"
data ConnType = ConnContact | ConnMember | ConnSndFile | ConnRcvFile
deriving (Eq, Show)
instance FromField ConnType where fromField = fromTextField_ connTypeT
instance ToField ConnType where toField = toField . serializeConnType
connTypeT :: Text -> Maybe ConnType
connTypeT = \case
"contact" -> Just ConnContact
"member" -> Just ConnMember
"snd_file" -> Just ConnSndFile
"rcv_file" -> Just ConnRcvFile
_ -> Nothing
serializeConnType :: ConnType -> Text
serializeConnType = \case
ConnContact -> "contact"
ConnMember -> "member"
ConnSndFile -> "snd_file"
ConnRcvFile -> "rcv_file"
data NewConnection = NewConnection
{ agentConnId :: ByteString,
connLevel :: Int,
viaConn :: Maybe Int64
}
data GroupMemberIntro = GroupMemberIntro
{ introId :: Int64,
reMember :: GroupMember,
toMember :: GroupMember,
introStatus :: GroupMemberIntroStatus,
introInvitation :: Maybe IntroInvitation
}
data GroupMemberIntroStatus
= GMIntroPending
| GMIntroSent
| GMIntroInvReceived
| GMIntroInvForwarded
| GMIntroReConnected
| GMIntroToConnected
| GMIntroConnected
instance FromField GroupMemberIntroStatus where fromField = fromTextField_ introStatusT
instance ToField GroupMemberIntroStatus where toField = toField . serializeIntroStatus
introStatusT :: Text -> Maybe GroupMemberIntroStatus
introStatusT = \case
"new" -> Just GMIntroPending
"sent" -> Just GMIntroSent
"rcv" -> Just GMIntroInvReceived
"fwd" -> Just GMIntroInvForwarded
"re-con" -> Just GMIntroReConnected
"to-con" -> Just GMIntroToConnected
"con" -> Just GMIntroConnected
_ -> Nothing
serializeIntroStatus :: GroupMemberIntroStatus -> Text
serializeIntroStatus = \case
GMIntroPending -> "new"
GMIntroSent -> "sent"
GMIntroInvReceived -> "rcv"
GMIntroInvForwarded -> "fwd"
GMIntroReConnected -> "re-con"
GMIntroToConnected -> "to-con"
GMIntroConnected -> "con"
@@ -1,6 +1,5 @@
module Simplex.Chat.Util where
import Control.Monad (when)
import Data.ByteString.Char8 (ByteString)
import Data.Text (Text)
import Data.Text.Encoding (decodeUtf8With)
@@ -13,11 +12,5 @@ safeDecodeUtf8 = decodeUtf8With onError
ifM :: Monad m => m Bool -> m a -> m a -> m a
ifM ba t f = ba >>= \b -> if b then t else f
whenM :: Monad m => m Bool -> m () -> m ()
whenM ba a = ba >>= (`when` a)
unlessM :: Monad m => m Bool -> m () -> m ()
unlessM b = ifM b $ pure ()
eitherToMaybe :: Either a b -> Maybe b
eitherToMaybe = either (const Nothing) Just
+687
View File
@@ -0,0 +1,687 @@
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Simplex.Chat.View
( printToView,
showInvitation,
showChatError,
showContactDeleted,
showContactGroups,
showContactConnected,
showContactDisconnected,
showContactAnotherClient,
showContactSubscribed,
showContactSubError,
showGroupSubscribed,
showGroupEmpty,
showGroupRemoved,
showMemberSubError,
showReceivedMessage,
showReceivedGroupMessage,
showSentMessage,
showSentGroupMessage,
showSentFileInvitation,
showSentGroupFileInvitation,
showSentFileInfo,
showSndFileStart,
showSndFileComplete,
showSndFileCancelled,
showSndGroupFileCancelled,
showSndFileRcvCancelled,
receivedFileInvitation,
showRcvFileAccepted,
showRcvFileStart,
showRcvFileComplete,
showRcvFileCancelled,
showRcvFileSndCancelled,
showFileTransferStatus,
showSndFileSubError,
showRcvFileSubError,
showGroupCreated,
showGroupDeletedUser,
showGroupDeleted,
showSentGroupInvitation,
showReceivedGroupInvitation,
showJoinedGroupMember,
showUserJoinedGroup,
showJoinedGroupMemberConnecting,
showConnectedToGroupMember,
showDeletedMember,
showDeletedMemberUser,
showLeftMemberUser,
showLeftMember,
showGroupMembers,
showContactsMerged,
showUserProfile,
showUserProfileUpdated,
showContactUpdated,
showMessageError,
safeDecodeUtf8,
msgPlain,
)
where
import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Data.ByteString.Char8 (ByteString)
import Data.Composition ((.:), (.:.))
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intersperse, sortOn)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (DiffTime, UTCTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Time.LocalTime (TimeZone, ZonedTime, getCurrentTimeZone, getZonedTime, localDay, localTimeOfDay, timeOfDayToTime, utcToLocalTime, zonedTimeToLocalTime)
import Numeric (showFFloat)
import Simplex.Chat.Controller
import Simplex.Chat.Markdown
import Simplex.Chat.Store (StoreError (..))
import Simplex.Chat.Styled
import Simplex.Chat.Terminal (printToTerminal)
import Simplex.Chat.Types
import Simplex.Chat.Util (safeDecodeUtf8)
import Simplex.Messaging.Agent.Protocol
import System.Console.ANSI.Types
type ChatReader m = (MonadUnliftIO m, MonadReader ChatController m)
showInvitation :: ChatReader m => SMPQueueInfo -> m ()
showInvitation = printToView . invitation
showChatError :: ChatReader m => ChatError -> m ()
showChatError = printToView . chatError
showContactDeleted :: ChatReader m => ContactName -> m ()
showContactDeleted = printToView . contactDeleted
showContactGroups :: ChatReader m => ContactName -> [GroupName] -> m ()
showContactGroups = printToView .: contactGroups
showContactConnected :: ChatReader m => Contact -> m ()
showContactConnected = printToView . contactConnected
showContactDisconnected :: ChatReader m => ContactName -> m ()
showContactDisconnected = printToView . contactDisconnected
showContactAnotherClient :: ChatReader m => ContactName -> m ()
showContactAnotherClient = printToView . contactAnotherClient
showContactSubscribed :: ChatReader m => ContactName -> m ()
showContactSubscribed = printToView . contactSubscribed
showContactSubError :: ChatReader m => ContactName -> ChatError -> m ()
showContactSubError = printToView .: contactSubError
showGroupSubscribed :: ChatReader m => GroupName -> m ()
showGroupSubscribed = printToView . groupSubscribed
showGroupEmpty :: ChatReader m => GroupName -> m ()
showGroupEmpty = printToView . groupEmpty
showGroupRemoved :: ChatReader m => GroupName -> m ()
showGroupRemoved = printToView . groupRemoved
showMemberSubError :: ChatReader m => GroupName -> ContactName -> ChatError -> m ()
showMemberSubError = printToView .:. memberSubError
showReceivedMessage :: ChatReader m => ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> m ()
showReceivedMessage = showReceivedMessage_ . ttyFromContact
showReceivedGroupMessage :: ChatReader m => GroupName -> ContactName -> UTCTime -> [StyledString] -> MsgIntegrity -> m ()
showReceivedGroupMessage = showReceivedMessage_ .: ttyFromGroup
showReceivedMessage_ :: ChatReader m => StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> m ()
showReceivedMessage_ from utcTime msg mOk = printToView =<< liftIO (receivedMessage from utcTime msg mOk)
showSentMessage :: ChatReader m => ContactName -> ByteString -> m ()
showSentMessage = showSentMessage_ . ttyToContact
showSentGroupMessage :: ChatReader m => GroupName -> ByteString -> m ()
showSentGroupMessage = showSentMessage_ . ttyToGroup
showSentMessage_ :: ChatReader m => StyledString -> ByteString -> m ()
showSentMessage_ to msg = printToView =<< liftIO (sentMessage to msg)
showSentFileInvitation :: ChatReader m => ContactName -> FilePath -> m ()
showSentFileInvitation = showSentFileInvitation_ . ttyToContact
showSentGroupFileInvitation :: ChatReader m => GroupName -> FilePath -> m ()
showSentGroupFileInvitation = showSentFileInvitation_ . ttyToGroup
showSentFileInvitation_ :: ChatReader m => StyledString -> FilePath -> m ()
showSentFileInvitation_ to filePath = printToView =<< liftIO (sentFileInvitation to filePath)
showSentFileInfo :: ChatReader m => Int64 -> m ()
showSentFileInfo = printToView . sentFileInfo
showSndFileStart :: ChatReader m => SndFileTransfer -> m ()
showSndFileStart = printToView . sndFileStart
showSndFileComplete :: ChatReader m => SndFileTransfer -> m ()
showSndFileComplete = printToView . sndFileComplete
showSndFileCancelled :: ChatReader m => SndFileTransfer -> m ()
showSndFileCancelled = printToView . sndFileCancelled
showSndGroupFileCancelled :: ChatReader m => [SndFileTransfer] -> m ()
showSndGroupFileCancelled = printToView . sndGroupFileCancelled
showSndFileRcvCancelled :: ChatReader m => SndFileTransfer -> m ()
showSndFileRcvCancelled = printToView . sndFileRcvCancelled
showRcvFileAccepted :: ChatReader m => RcvFileTransfer -> FilePath -> m ()
showRcvFileAccepted = printToView .: rcvFileAccepted
showRcvFileStart :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileStart = printToView . rcvFileStart
showRcvFileComplete :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileComplete = printToView . rcvFileComplete
showRcvFileCancelled :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileCancelled = printToView . rcvFileCancelled
showRcvFileSndCancelled :: ChatReader m => RcvFileTransfer -> m ()
showRcvFileSndCancelled = printToView . rcvFileSndCancelled
showFileTransferStatus :: ChatReader m => (FileTransfer, [Integer]) -> m ()
showFileTransferStatus = printToView . fileTransferStatus
showSndFileSubError :: ChatReader m => SndFileTransfer -> ChatError -> m ()
showSndFileSubError = printToView .: sndFileSubError
showRcvFileSubError :: ChatReader m => RcvFileTransfer -> ChatError -> m ()
showRcvFileSubError = printToView .: rcvFileSubError
showGroupCreated :: ChatReader m => Group -> m ()
showGroupCreated = printToView . groupCreated
showGroupDeletedUser :: ChatReader m => GroupName -> m ()
showGroupDeletedUser = printToView . groupDeletedUser
showGroupDeleted :: ChatReader m => GroupName -> GroupMember -> m ()
showGroupDeleted = printToView .: groupDeleted
showSentGroupInvitation :: ChatReader m => GroupName -> ContactName -> m ()
showSentGroupInvitation = printToView .: sentGroupInvitation
showReceivedGroupInvitation :: ChatReader m => Group -> ContactName -> GroupMemberRole -> m ()
showReceivedGroupInvitation = printToView .:. receivedGroupInvitation
showJoinedGroupMember :: ChatReader m => GroupName -> GroupMember -> m ()
showJoinedGroupMember = printToView .: joinedGroupMember
showUserJoinedGroup :: ChatReader m => GroupName -> m ()
showUserJoinedGroup = printToView . userJoinedGroup
showJoinedGroupMemberConnecting :: ChatReader m => GroupName -> GroupMember -> GroupMember -> m ()
showJoinedGroupMemberConnecting = printToView .:. joinedGroupMemberConnecting
showConnectedToGroupMember :: ChatReader m => GroupName -> GroupMember -> m ()
showConnectedToGroupMember = printToView .: connectedToGroupMember
showDeletedMember :: ChatReader m => GroupName -> Maybe GroupMember -> Maybe GroupMember -> m ()
showDeletedMember = printToView .:. deletedMember
showDeletedMemberUser :: ChatReader m => GroupName -> GroupMember -> m ()
showDeletedMemberUser = printToView .: deletedMemberUser
showLeftMemberUser :: ChatReader m => GroupName -> m ()
showLeftMemberUser = printToView . leftMemberUser
showLeftMember :: ChatReader m => GroupName -> GroupMember -> m ()
showLeftMember = printToView .: leftMember
showGroupMembers :: ChatReader m => Group -> m ()
showGroupMembers = printToView . groupMembers
showContactsMerged :: ChatReader m => Contact -> Contact -> m ()
showContactsMerged = printToView .: contactsMerged
showUserProfile :: ChatReader m => Profile -> m ()
showUserProfile = printToView . userProfile
showUserProfileUpdated :: ChatReader m => User -> User -> m ()
showUserProfileUpdated = printToView .: userProfileUpdated
showContactUpdated :: ChatReader m => Contact -> Contact -> m ()
showContactUpdated = printToView .: contactUpdated
showMessageError :: ChatReader m => Text -> Text -> m ()
showMessageError = printToView .: messageError
invitation :: SMPQueueInfo -> [StyledString]
invitation qInfo =
[ "pass this invitation to your contact (via another channel): ",
"",
(plain . serializeSmpQueueInfo) qInfo,
"",
"and ask them to connect: " <> highlight' "/c <invitation_above>"
]
contactDeleted :: ContactName -> [StyledString]
contactDeleted c = [ttyContact c <> ": contact is deleted"]
contactGroups :: ContactName -> [GroupName] -> [StyledString]
contactGroups c gNames = [ttyContact c <> ": contact cannot be deleted, it is a member of the group(s) " <> ttyGroups gNames]
where
ttyGroups :: [GroupName] -> StyledString
ttyGroups [] = ""
ttyGroups [g] = ttyGroup g
ttyGroups (g : gs) = ttyGroup g <> ", " <> ttyGroups gs
contactConnected :: Contact -> [StyledString]
contactConnected ct = [ttyFullContact ct <> ": contact is connected"]
contactDisconnected :: ContactName -> [StyledString]
contactDisconnected c = [ttyContact c <> ": disconnected from server (messages will be queued)"]
contactAnotherClient :: ContactName -> [StyledString]
contactAnotherClient c = [ttyContact c <> ": contact is connected to another client"]
contactSubscribed :: ContactName -> [StyledString]
contactSubscribed c = [ttyContact c <> ": connected to server"]
contactSubError :: ContactName -> ChatError -> [StyledString]
contactSubError c e = [ttyContact c <> ": contact error " <> sShow e]
groupSubscribed :: GroupName -> [StyledString]
groupSubscribed g = [ttyGroup g <> ": connected to server(s)"]
groupEmpty :: GroupName -> [StyledString]
groupEmpty g = [ttyGroup g <> ": group is empty"]
groupRemoved :: GroupName -> [StyledString]
groupRemoved g = [ttyGroup g <> ": you are no longer a member or group deleted"]
memberSubError :: GroupName -> ContactName -> ChatError -> [StyledString]
memberSubError g c e = [ttyGroup g <> " member " <> ttyContact c <> " error: " <> sShow e]
groupCreated :: Group -> [StyledString]
groupCreated g@Group {localDisplayName} =
[ "group " <> ttyFullGroup g <> " is created",
"use " <> highlight ("/a " <> localDisplayName <> " <name>") <> " to add members"
]
groupDeletedUser :: GroupName -> [StyledString]
groupDeletedUser g = groupDeleted_ g Nothing
groupDeleted :: GroupName -> GroupMember -> [StyledString]
groupDeleted g m = groupDeleted_ g (Just m) <> ["use " <> highlight ("/d #" <> g) <> " to delete the local copy of the group"]
groupDeleted_ :: GroupName -> Maybe GroupMember -> [StyledString]
groupDeleted_ g m = [ttyGroup g <> ": " <> memberOrUser m <> " deleted the group"]
sentGroupInvitation :: GroupName -> ContactName -> [StyledString]
sentGroupInvitation g c = ["invitation to join the group " <> ttyGroup g <> " sent to " <> ttyContact c]
receivedGroupInvitation :: Group -> ContactName -> GroupMemberRole -> [StyledString]
receivedGroupInvitation g@Group {localDisplayName} c role =
[ ttyFullGroup g <> ": " <> ttyContact c <> " invites you to join the group as " <> plain (serializeMemberRole role),
"use " <> highlight ("/j " <> localDisplayName) <> " to accept"
]
joinedGroupMember :: GroupName -> GroupMember -> [StyledString]
joinedGroupMember g m = [ttyGroup g <> ": " <> ttyMember m <> " joined the group "]
userJoinedGroup :: GroupName -> [StyledString]
userJoinedGroup g = [ttyGroup g <> ": you joined the group"]
joinedGroupMemberConnecting :: GroupName -> GroupMember -> GroupMember -> [StyledString]
joinedGroupMemberConnecting g host m = [ttyGroup g <> ": " <> ttyMember host <> " added " <> ttyFullMember m <> " to the group (connecting...)"]
connectedToGroupMember :: GroupName -> GroupMember -> [StyledString]
connectedToGroupMember g m = [ttyGroup g <> ": " <> connectedMember m <> " is connected"]
deletedMember :: GroupName -> Maybe GroupMember -> Maybe GroupMember -> [StyledString]
deletedMember g by m = [ttyGroup g <> ": " <> memberOrUser by <> " removed " <> memberOrUser m <> " from the group"]
deletedMemberUser :: GroupName -> GroupMember -> [StyledString]
deletedMemberUser g by = deletedMember g (Just by) Nothing <> groupPreserved g
leftMemberUser :: GroupName -> [StyledString]
leftMemberUser g = leftMember_ g Nothing <> groupPreserved g
leftMember :: GroupName -> GroupMember -> [StyledString]
leftMember g m = leftMember_ g (Just m)
leftMember_ :: GroupName -> Maybe GroupMember -> [StyledString]
leftMember_ g m = [ttyGroup g <> ": " <> memberOrUser m <> " left the group"]
groupPreserved :: GroupName -> [StyledString]
groupPreserved g = ["use " <> highlight ("/d #" <> g) <> " to delete the group"]
memberOrUser :: Maybe GroupMember -> StyledString
memberOrUser = maybe "you" ttyMember
connectedMember :: GroupMember -> StyledString
connectedMember m = case memberCategory m of
GCPreMember -> "member " <> ttyFullMember m
GCPostMember -> "new member " <> ttyMember m -- without fullName as as it was shown in joinedGroupMemberConnecting
_ -> "member " <> ttyMember m -- these case is not used
groupMembers :: Group -> [StyledString]
groupMembers Group {membership, members} = map groupMember . filter (not . removedOrLeft) $ membership : members
where
removedOrLeft m = let s = memberStatus m in s == GSMemRemoved || s == GSMemLeft
groupMember m = ttyFullMember m <> ": " <> role m <> ", " <> category m <> status m
role = plain . serializeMemberRole . memberRole
category m = case memberCategory m of
GCUserMember -> "you, "
GCInviteeMember -> "invited, "
GCHostMember -> "host, "
_ -> ""
status m = case memberStatus m of
GSMemRemoved -> "removed"
GSMemLeft -> "left"
GSMemInvited -> "not yet joined"
GSMemConnected -> "connected"
GSMemComplete -> "connected"
GSMemCreator -> "created group"
_ -> ""
contactsMerged :: Contact -> Contact -> [StyledString]
contactsMerged _to@Contact {localDisplayName = c1} _from@Contact {localDisplayName = c2} =
[ "contact " <> ttyContact c2 <> " is merged into " <> ttyContact c1,
"use " <> ttyToContact c1 <> highlight' "<message>" <> " to send messages"
]
userProfile :: Profile -> [StyledString]
userProfile Profile {displayName, fullName} =
[ "user profile: " <> ttyFullName displayName fullName,
"use " <> highlight' "/p <display name> [<full name>]" <> " to change it",
"(the updated profile will be sent to all your contacts)"
]
userProfileUpdated :: User -> User -> [StyledString]
userProfileUpdated
User {localDisplayName = n, profile = Profile {fullName}}
User {localDisplayName = n', profile = Profile {fullName = fullName'}}
| n == n' && fullName == fullName' = []
| n == n' = ["user full name " <> (if T.null fullName' || fullName' == n' then "removed" else "changed to " <> plain fullName') <> notified]
| otherwise = ["user profile is changed to " <> ttyFullName n' fullName' <> notified]
where
notified = " (your contacts are notified)"
contactUpdated :: Contact -> Contact -> [StyledString]
contactUpdated
Contact {localDisplayName = n, profile = Profile {fullName}}
Contact {localDisplayName = n', profile = Profile {fullName = fullName'}}
| n == n' && fullName == fullName' = []
| n == n' = ["contact " <> ttyContact n <> fullNameUpdate]
| otherwise =
[ "contact " <> ttyContact n <> " changed to " <> ttyFullName n' fullName',
"use " <> ttyToContact n' <> highlight' "<message>" <> " to send messages"
]
where
fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName'
messageError :: Text -> Text -> [StyledString]
messageError prefix err = [plain prefix <> ": " <> plain err]
receivedMessage :: StyledString -> UTCTime -> [StyledString] -> MsgIntegrity -> IO [StyledString]
receivedMessage from utcTime msg mOk = do
t <- formatUTCTime <$> getCurrentTimeZone <*> getZonedTime
pure $ prependFirst (t <> " " <> from) msg ++ showIntegrity mOk
where
formatUTCTime :: TimeZone -> ZonedTime -> StyledString
formatUTCTime localTz currentTime =
let localTime = utcToLocalTime localTz utcTime
format =
if (localDay localTime < localDay (zonedTimeToLocalTime currentTime))
&& (timeOfDayToTime (localTimeOfDay localTime) > (6 * 60 * 60 :: DiffTime))
then "%m-%d" -- if message is from yesterday or before and 6 hours has passed since midnight
else "%H:%M"
in styleTime $ formatTime defaultTimeLocale format localTime
showIntegrity :: MsgIntegrity -> [StyledString]
showIntegrity MsgOk = []
showIntegrity (MsgError err) = msgError $ case err of
MsgSkipped fromId toId ->
"skipped message ID " <> show fromId
<> if fromId == toId then "" else ".." <> show toId
MsgBadId msgId -> "unexpected message ID " <> show msgId
MsgBadHash -> "incorrect message hash"
MsgDuplicate -> "duplicate message ID"
msgError :: String -> [StyledString]
msgError s = [styled (Colored Red) s]
sentMessage :: StyledString -> ByteString -> IO [StyledString]
sentMessage to msg = sendWithTime_ to . msgPlain $ safeDecodeUtf8 msg
sentFileInvitation :: StyledString -> FilePath -> IO [StyledString]
sentFileInvitation to f = sendWithTime_ ("/f " <> to) [ttyFilePath f]
sendWithTime_ :: StyledString -> [StyledString] -> IO [StyledString]
sendWithTime_ to styledMsg = do
time <- formatTime defaultTimeLocale "%H:%M" <$> getZonedTime
pure $ prependFirst (styleTime time <> " " <> to) styledMsg
prependFirst :: StyledString -> [StyledString] -> [StyledString]
prependFirst s [] = [s]
prependFirst s (s' : ss) = (s <> s') : ss
msgPlain :: Text -> [StyledString]
msgPlain = map styleMarkdownText . T.lines
sentFileInfo :: Int64 -> [StyledString]
sentFileInfo fileId =
["use " <> highlight ("/fc " <> show fileId) <> " to cancel sending"]
sndFileStart :: SndFileTransfer -> [StyledString]
sndFileStart = sendingFile_ "started"
sndFileComplete :: SndFileTransfer -> [StyledString]
sndFileComplete = sendingFile_ "completed"
sndFileCancelled :: SndFileTransfer -> [StyledString]
sndFileCancelled = sendingFile_ "cancelled"
sndGroupFileCancelled :: [SndFileTransfer] -> [StyledString]
sndGroupFileCancelled 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]
sendingFile_ :: StyledString -> SndFileTransfer -> [StyledString]
sendingFile_ status ft@SndFileTransfer {recipientDisplayName = c} =
[status <> " sending " <> sndFile ft <> " to " <> ttyContact c]
sndFileRcvCancelled :: SndFileTransfer -> [StyledString]
sndFileRcvCancelled ft@SndFileTransfer {recipientDisplayName = c} =
[ttyContact c <> " cancelled receiving " <> sndFile ft]
sndFile :: SndFileTransfer -> StyledString
sndFile SndFileTransfer {fileId, fileName} = fileTransfer fileId fileName
receivedFileInvitation :: RcvFileTransfer -> [StyledString]
receivedFileInvitation RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} =
[ "sends file " <> ttyFilePath fileName <> " (" <> humanReadableSize fileSize <> " / " <> sShow fileSize <> " bytes)",
"use " <> highlight ("/fr " <> show fileId <> " [<dir>/ | <path>]") <> " to receive it"
]
humanReadableSize :: Integer -> StyledString
humanReadableSize size
| size < kB = sShow size <> " bytes"
| size < mB = hrSize kB "KiB"
| size < gB = hrSize mB "MiB"
| otherwise = hrSize gB "GiB"
where
hrSize sB name = plain $ unwords [showFFloat (Just 1) (fromIntegral size / (fromIntegral sB :: Double)) "", name]
kB = 1024
mB = kB * 1024
gB = mB * 1024
rcvFileAccepted :: RcvFileTransfer -> FilePath -> [StyledString]
rcvFileAccepted RcvFileTransfer {fileId, senderDisplayName = c} filePath =
["saving file " <> sShow fileId <> " from " <> ttyContact c <> " to " <> plain filePath]
rcvFileStart :: RcvFileTransfer -> [StyledString]
rcvFileStart = receivingFile_ "started"
rcvFileComplete :: RcvFileTransfer -> [StyledString]
rcvFileComplete = receivingFile_ "completed"
rcvFileCancelled :: RcvFileTransfer -> [StyledString]
rcvFileCancelled = receivingFile_ "cancelled"
receivingFile_ :: StyledString -> RcvFileTransfer -> [StyledString]
receivingFile_ status ft@RcvFileTransfer {senderDisplayName = c} =
[status <> " receiving " <> rcvFile ft <> " from " <> ttyContact c]
rcvFileSndCancelled :: RcvFileTransfer -> [StyledString]
rcvFileSndCancelled ft@RcvFileTransfer {senderDisplayName = c} =
[ttyContact c <> " cancelled sending " <> rcvFile ft]
rcvFile :: RcvFileTransfer -> StyledString
rcvFile RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} = fileTransfer fileId fileName
fileTransfer :: Int64 -> String -> StyledString
fileTransfer fileId fileName = "file " <> sShow fileId <> " (" <> ttyFilePath fileName <> ")"
fileTransferStatus :: (FileTransfer, [Integer]) -> [StyledString]
fileTransferStatus (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"
fileTransferStatus (FTSnd [], _) = ["no file transfers (empty group)"]
fileTransferStatus (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
where
fs = fileStatus :: SndFileTransfer -> FileStatus
membersTransferStatus [] = []
membersTransferStatus ts@(SndFileTransfer {fileStatus, fileSize, chunkSize} : _) = [sndStatus <> ": " <> listMembers ts]
where
sndStatus = case fileStatus of
FSNew -> "not accepted"
FSAccepted -> "just started"
FSConnected -> "in progress (" <> sShow (sum chunksNum * chunkSize * 100 `div` (toInteger (length chunksNum) * fileSize)) <> "%)"
FSComplete -> "complete"
FSCancelled -> "cancelled"
fileTransferStatus (FTRcv ft@RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileSize}, fileStatus, chunkSize}, chunksNum) =
["receiving " <> rcvFile ft <> " " <> rcvStatus]
where
rcvStatus = case fileStatus of
RFSNew -> "not accepted yet, use " <> highlight ("/fr " <> show fileId) <> " to receive file"
RFSAccepted _ -> "just started"
RFSConnected _ -> "progress " <> fileProgress chunksNum chunkSize fileSize
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)
fileProgress :: [Integer] -> Integer -> Integer -> StyledString
fileProgress chunksNum chunkSize fileSize =
sShow (sum chunksNum * chunkSize * 100 `div` fileSize) <> "% of " <> humanReadableSize fileSize
sndFileSubError :: SndFileTransfer -> ChatError -> [StyledString]
sndFileSubError SndFileTransfer {fileId, fileName} e =
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
rcvFileSubError :: RcvFileTransfer -> ChatError -> [StyledString]
rcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e =
["received file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
chatError :: ChatError -> [StyledString]
chatError = \case
ChatError err -> case err of
CEGroupDuplicateMember c -> ["contact " <> ttyContact c <> " is already in the group"]
CEGroupDuplicateMemberId -> ["cannot add member - duplicate member ID"]
CEGroupUserRole -> ["you have insufficient permissions for this group command"]
CEGroupContactRole c -> ["contact " <> ttyContact c <> " has insufficient permissions for this group action"]
CEGroupNotJoined g -> ["you did not join this group, use " <> highlight ("/join #" <> g)]
CEGroupMemberNotActive -> ["you cannot invite other members yet, try later"]
CEGroupMemberUserRemoved -> ["you are no longer the member of the group"]
CEGroupMemberNotFound c -> ["contact " <> ttyContact c <> " is not a group member"]
CEGroupInternal s -> ["chat group bug: " <> plain s]
CEFileNotFound f -> ["file not found: " <> plain f]
CEFileAlreadyReceiving f -> ["file is already accepted: " <> plain f]
CEFileAlreadyExists f -> ["file already exists: " <> plain f]
CEFileRead f e -> ["cannot read file " <> plain f, sShow e]
CEFileWrite f e -> ["cannot write file " <> plain f, sShow e]
CEFileSend fileId e -> ["error sending file " <> sShow fileId <> ": " <> sShow e]
CEFileRcvChunk e -> ["error receiving file: " <> plain e]
CEFileInternal e -> ["file error: " <> plain e]
-- e -> ["chat error: " <> sShow e]
ChatErrorStore err -> case err of
SEDuplicateName -> ["this display name is already used by user, contact or group"]
SEContactNotFound c -> ["no contact " <> ttyContact c]
SEContactNotReady c -> ["contact " <> ttyContact c <> " is not active yet"]
SEGroupNotFound g -> ["no group " <> ttyGroup g]
SEGroupAlreadyJoined -> ["you already joined this group"]
SEFileNotFound fileId -> fileNotFound fileId
SESndFileNotFound fileId -> fileNotFound fileId
SERcvFileNotFound fileId -> fileNotFound fileId
e -> ["chat db error: " <> sShow e]
ChatErrorAgent e -> ["smp agent error: " <> sShow e]
ChatErrorMessage e -> ["chat message error: " <> sShow e]
where
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
printToView :: (MonadUnliftIO m, MonadReader ChatController m) => [StyledString] -> m ()
printToView s = asks chatTerminal >>= liftIO . (`printToTerminal` s)
ttyContact :: ContactName -> StyledString
ttyContact = styled (Colored Green)
ttyFullContact :: Contact -> StyledString
ttyFullContact Contact {localDisplayName, profile = Profile {fullName}} =
ttyFullName localDisplayName fullName
ttyMember :: GroupMember -> StyledString
ttyMember GroupMember {localDisplayName} = ttyContact localDisplayName
ttyFullMember :: GroupMember -> StyledString
ttyFullMember GroupMember {localDisplayName, memberProfile = Profile {fullName}} =
ttyFullName localDisplayName fullName
ttyFullName :: ContactName -> Text -> StyledString
ttyFullName c fullName = ttyContact c <> optFullName c fullName
ttyToContact :: ContactName -> StyledString
ttyToContact c = styled (Colored Cyan) $ "@" <> c <> " "
ttyFromContact :: ContactName -> StyledString
ttyFromContact c = styled (Colored Yellow) $ c <> "> "
ttyGroup :: GroupName -> StyledString
ttyGroup g = styled (Colored Blue) $ "#" <> g
ttyFullGroup :: Group -> StyledString
ttyFullGroup Group {localDisplayName, groupProfile = GroupProfile {fullName}} =
ttyGroup localDisplayName <> optFullName localDisplayName fullName
ttyFromGroup :: GroupName -> ContactName -> StyledString
ttyFromGroup g c = styled (Colored Yellow) $ "#" <> g <> " " <> c <> "> "
ttyToGroup :: GroupName -> StyledString
ttyToGroup g = styled (Colored Cyan) $ "#" <> g <> " "
ttyFilePath :: FilePath -> StyledString
ttyFilePath = plain
optFullName :: ContactName -> Text -> StyledString
optFullName localDisplayName fullName
| T.null fullName || localDisplayName == fullName = ""
| otherwise = plain (" (" <> fullName <> ")")
highlight :: StyledFormat a => a -> StyledString
highlight = styled (Colored Cyan)
highlight' :: String -> StyledString
highlight' = highlight
styleTime :: String -> StyledString
styleTime = Styled [SetColor Foreground Vivid Black]
@@ -22,6 +22,9 @@ The syntax of the message inside agent MSG:
```abnf
agentMessageBody = [chatMsgId] SP msgEvent SP [parameters] SP [contentParts [SP msgBodyParts]]
chatMsgId = 1*DIGIT ; used to refer to previous message;
; in the group should only be used in messages sent to all members,
; which is the main reason not to use external agent ID -
; some messages are sent only to one member
msgEvent = protocolNamespace 1*("." msgTypeName)
protocolNamespace = 1*ALPHA ; "x" for all events defined in the protocol
msgTypeName = 1*ALPHA
@@ -65,219 +68,6 @@ refMsgHash = 16*16(OCTET) ; SHA256 of agent message body
' x.file name,size x.text:NNN <invitation> '
```
Chat message JTD:
```jsonc
{
"properties": {
"msgId": {"type": "string"},
"minVersion": {"type": "uint16"}, // Word16
"maxVersion": {"type": "uint16"}, // Word16
"event": {"type": "string"}, // Text e.g. s.ok
"params": {"values": {}}, // Map Text Value
},
"optionalProperties": {
"dag": {"type": "string"}
}
}
```
Events:
```jsonc
"event": "x.msg.new" // XMsgNew
"params": // MsgContent
{
"content": {
"msgType": "text",
// field "files" can be represented in content as contentType "file" with length prepended or as complex contentData
"text": "<msg text>"
}
// "content": [
// free form contentType for extensibility and/or complex content types? e.g. MIME
// could it be useful if contentData was free form as well? currently it is ByteString
// {"contentType": <content type>, "contentData": "<content data>"},
// ...
// {"contentType": <content type N>, "contentData": "<content data N>"}
// ]
}
"event": "x.file" // XFile; TODO rename into x.file.inv?
"params": // FileInvitation
{
"file": {
"fileName": "<file name>",
"fileSize": <file size>, // integer
"fileConnReq": "<file conn req>"
}
}
"event": "x.file.acpt" // XFileAcpt
"params": // String
{
"fileName": "<file name>"
}
"event": "x.info" // XInfo
"params": // Profile
{
"profile": {
"displayName": "<display name>",
"fullName": "<full name>"
}
}
"event": "x.contact" // XContact
"params": // Profile (Maybe MsgContent)
{
"profile": {
"displayName": "<display name>",
"fullName": "<full name>"
},
"content": {
"msgType": "text",
"text": "<msg text>"
} // optional
}
"event": "x.grp.inv" // XGrpInv
"params": // GroupInvitation
{
"groupInvitation": {
"fromMember": {
"memberId": "<from_member ID>",
"memberRole": "<from_member role>"
},
"invitedMember": {
"memberId": "<invited_member ID>",
"memberRole": "<invited_member role>"
},
"connRequest": "<conn request>",
"groupProfile": {
"displayName": "<display name>",
"fullName": "<full name>"
}
}
}
"event": "x.grp.acpt" // XGrpAcpt
"params": // MemberId
{
"memberId": "<member ID>"
}
"event": "x.grp.mem.new" // XGrpMemNew
"params": // MemberInfo
{
"memberInfo": {
"memberId": "<member ID>",
"memberRole": "<member role>",
"profile": {
"displayName": "<display name>",
"fullName": "<full name>"
}
}
}
"event": "x.grp.mem.intro" // XGrpMemIntro
"params": // MemberInfo
{
"memberInfo": {
"memberId": "<member ID>",
"memberRole": "<member role>",
"profile": {
"displayName": "<display name>",
"fullName": "<full name>"
}
}
}
"event": "x.grp.mem.inv" // XGrpMemInv
"params": // MemberId IntroInvitation
{
"memberId": "<member ID>",
"memberIntro": {
"groupConnReq": "<group conn req>",
"directConnReq": "<direct conn req>"
}
}
"event": "x.grp.mem.fwd" // XGrpMemFwd
"params": // MemberInfo IntroInvitation
{
"memberInfo": {
"memberId": "<member ID>",
"memberRole": "<member role>",
"profile": {
"displayName": "<display name>",
"fullName": "<full name>"
},
},
"memberIntro": {
"groupConnReq": "<group conn req>",
"directConnReq": "<direct conn req>"
}
}
"event": "x.grp.mem.info" // XGrpMemInfo
"params": // MemberId Profile
{
"memberId": "<member ID>",
"profile": {
"displayName": "<display name>",
"fullName": "<full name>"
}
}
"event": "x.grp.mem.con" // XGrpMemCon
"params": // MemberId
{
"memberId": "<member ID>"
}
"event": "x.grp.mem.con.all" // XGrpMemConAll
"params": // MemberId
{
"memberId": "<member ID>"
}
"event": "x.grp.mem.del" // XGrpMemDel
"params": // MemberId
{
"memberId": "<member ID>"
}
"event": "x.grp.leave" // XGrpLeave
"params":
{}
"event": "x.grp.del" // XGrpDel
"params":
{}
"event": "x.info.probe" // XInfoProbe
"params": // ByteString
{
"probe": "<probe>"
}
"event": "x.info.probe.check" // XInfoProbeCheck
"params": // ByteString
{
"probeHash": "<probe hash>"
}
"event": "x.info.probe.ok" // XInfoProbeOk
"params": // ByteString
{
"probe": "<probe>"
}
"event": "x.ok" // XOk
"params":
{}
```
### Group protocol
#### Add group member

Some files were not shown because too many files have changed in this diff Show More