Compare commits

...

62 Commits

Author SHA1 Message Date
Evgeny Poberezkin 6ba7d208c8 terminal: version 1.3.2 (#442) 2022-03-14 20:58:53 +00:00
Evgeny Poberezkin 22ff17aec9 Merge PR #434 - v1.2 release 2022-03-12 20:28:43 +00:00
Evgeny Poberezkin b2650947a9 android: update build (11) 2022-03-12 17:24:29 +00:00
Evgeny Poberezkin 604bf0c485 android: smaller fonts, bigger line height (#433) 2022-03-12 16:57:30 +00:00
Evgeny Poberezkin b7bf3678e5 fix: markdown and links interaction/copy in messages (#432) 2022-03-12 16:52:04 +00:00
Evgeny Poberezkin b0430f7eee android: update version 10 (1.2) 2022-03-11 19:11:52 +00:00
Evgeny Poberezkin 7d3e440a47 ios: update build (26) 2022-03-11 18:24:38 +00:00
Evgeny Poberezkin 6877261b9c ios: fit smaller screens (#429)
* ios: fit smaller screens

* s/or/and/
2022-03-11 17:52:11 +00:00
Evgeny Poberezkin eef45a6015 ios: update haskell lib, version 1.2 (25) 2022-03-11 11:32:57 +00:00
Evgeny Poberezkin 0aee431527 update readme 2022-03-11 07:37:13 +00:00
John Roberts 90a18186d9 configurable smp servers (#366, #411); core: profile images (#384)
* core: configurable smp servers (#366)

* core: update simplexmq hash

* core: update simplexmq hash (fix SMPServer json encoding)

* core: fix crashing on supplying duplicate SMP servers

* core: update simplexmq hash (remove SMPServer FromJSON)

* core: update simplexmq hash (merged master)

* core: profile images (#384)

* adding initial RFC

* adding migration SQL

* update RFC

* linting

* Apply suggestions from code review

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* refine RFC

* add avatars db migration to Store.hs

* initial chages to have images in users/groups

* fix protocol tests

* update SQL & MobileTests

* minor bug fixes

* add missing comma

* fix query error

* refactor and update  functions

* bug fixes + testing

* update to parse base64 web format images

* fix parsing and use valid padded base64 encoded image

* fix typos

* respose to and suggestions from review

* fix: typo

* refactor: avatars -> profile_images

* fix: typo

* swap updateProfile parameters

* remove TODO

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>

* ios, android: configurable smp servers (only model and api for android) (#392)

* android: configurable smp servers (ui)

* fix thumb color, fix text field color in dark mode

* update simplexmq hash (configurable servers in master)

Co-authored-by: IanRDavies <ian_davies_@hotmail.co.uk>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-10 15:45:40 +04:00
IanRDavies 38aea7c455 use relative sizing when scaling the QR code (#417)
* use relative sizing when scaling the QR code

* linting

* properly implement image scaling

* remove extra horizontal padding

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-10 10:36:21 +00:00
Evgeny Poberezkin e272048f24 ios: date/time formatting now respects locale settings (#420) 2022-03-09 22:35:33 +00:00
Evgeny Poberezkin 6aa9f208ee Merge pull request #418 from simplex-chat/id/android/fix-chat-scrolling
id/android/fix chat scrolling
2022-03-09 20:06:01 +00:00
IanRDavies b749bf7b08 fix scrolling with keyboard 2022-03-09 18:54:19 +00:00
IanRDavies ff3daed4c6 fix scrolling issue using save/load state 2022-03-09 16:30:47 +00:00
IanRDavies e90e10bd26 add variable to monitor scrolling as scroll fix 2022-03-09 15:56:08 +00:00
Evgeny Poberezkin c6a49b048f Merge pull request #410 from simplex-chat/master
AppStore 1.1 release (build 24)
2022-03-08 15:57:56 +00:00
Evgeny Poberezkin 29af079a8f ios: update build number (24), app store 1.1 submission - fixing iPhone 7 etc. 2022-03-08 15:19:14 +00:00
Evgeny Poberezkin 9bb6be8e60 update readme (#409) 2022-03-08 13:16:15 +00:00
Evgeny Poberezkin 226daa990f blog: apps announcement draft (#405)
* blog: apps announcement draft

* update mobile apps post

* update blog post

* add "what is simplex"
2022-03-08 12:23:08 +00:00
Evgeny Poberezkin b8e3809452 Merge pull request #408 from simplex-chat/stable
merge stable back to master
2022-03-08 11:58:29 +00:00
Evgeny Poberezkin 47881f77d9 Merge pull request #407 from simplex-chat/angerman/bump-haskell-nix
bump haskell.nix to support iPhone 7
2022-03-08 10:32:55 +00:00
Moritz Angermann 69d0a5286e bump haskell.nix 2022-03-08 10:03:12 +00:00
Evgeny Poberezkin ebdd78edea remove iPad support, update build # (23) 2022-03-08 08:46:48 +00:00
Evgeny Poberezkin eff7c363d4 Merge pull request #403 from simplex-chat/master
app release
2022-03-07 16:07:16 +00:00
Evgeny Poberezkin 44cd482695 android: update version/build 0.4.2 (9) 2022-03-06 08:59:43 +00:00
Evgeny Poberezkin a801e0c5e9 ios: build 22, add iPad support 2022-03-05 22:33:44 +00:00
Efim Poberezkin 722f836714 core: sort group messages by timestamp (#400) 2022-03-05 20:32:29 +04:00
Efim Poberezkin 1dd62be4ef Merge pull request #387 from simplex-chat/master (v1.3.1 terminal app) 2022-03-05 14:01:39 +04:00
Efim Poberezkin 33f731e247 1.3.1 2022-03-05 13:01:16 +04:00
Efim Poberezkin 7cf139f856 prepare v1.3.1 (#398) 2022-03-05 12:34:48 +04:00
IanRDavies fd28c939f5 android: disable create button when display name is not valid (#396) 2022-03-04 14:51:25 +00:00
Evgeny Poberezkin 1ab68172cb ios: update version 1.1 (build 21) 2022-03-04 10:34:39 +00:00
Evgeny Poberezkin 87e9ae5a3e mobile: update verion/build: ios 1.0/21, android 0.4.1/8 2022-03-04 10:30:47 +00:00
Evgeny Poberezkin c47a7d78fe support for unknown message content types (#395)
* android: parse/serialize unknown chat items

* ios: more resilient decoding of MsgContent

* core: preserve JSON of unknown message content type in MCUknown, so it can be parsed once it is supported by the client
2022-03-03 08:32:25 +00:00
Evgeny Poberezkin b10b3a3434 ios: update libs 2022-03-02 20:15:22 +00:00
Efim Poberezkin 9d4de4b295 core: correctly set "yes to migrations" in agent config (#393) 2022-03-02 22:18:14 +04:00
Evgeny Poberezkin 2e5b123749 update simplexmq 2022-03-02 16:04:06 +00:00
Evgeny Poberezkin 3a6eaa3ddd android: update build number (7) 2022-03-02 15:57:21 +00:00
Evgeny Poberezkin eb42d739cb android: update version/build 0.4 (6) 2022-03-02 07:11:20 +00:00
Evgeny Poberezkin e5d5bd5ec8 android: disable background loading 2022-03-01 21:39:37 +00:00
Evgeny Poberezkin 24166a4271 android: background loading 2022-03-01 21:26:47 +00:00
Evgeny Poberezkin 232149817e ios: fix alerts, build 20 (#390) 2022-03-01 18:05:05 +00:00
Moritz Angermann a286834eb5 bump nixpkgs 2022-03-01 16:04:54 +00:00
Evgeny Poberezkin c500616bb4 update privacy policy, build number (#389) 2022-03-01 08:45:54 +00:00
Evgeny Poberezkin 42faa2e75b patch getentropy (#388)
* bump nixpkgs

* patch entropy

* bump haskell.nix

* remove file

Co-authored-by: Moritz Angermann <moritz.angermann@gmail.com>
2022-03-01 07:01:52 +00:00
Evgeny Poberezkin b19cf35d28 iOS: v1 AppStore release (#386)
* fix: disable create when display name is empty

* update version/build 1.0 (17)

* update build number (18)

* terms and privacy policy
2022-02-28 20:45:31 +00:00
Evgeny Poberezkin 4585c7b649 ios: update build version (16) 2022-02-28 14:31:55 +00:00
Evgeny Poberezkin 326a2a1877 ci: add x86 packages (#383) 2022-02-28 13:52:20 +00:00
Efim Poberezkin 8d057613f5 core: update default servers (#385) 2022-02-28 16:27:55 +04:00
Evgeny Poberezkin 0b00c2ad76 android: receiving messages in background; ios: background task completion (#382)
* android: receiving messages in background; ios: background task completion

* complete receiving and sending messages in background
2022-02-28 10:44:48 +00:00
Evgeny Poberezkin 310f56a9b3 update version/build: ios 0.4 (15), android 0.3 (5) 2022-02-27 19:25:40 +00:00
Evgeny Poberezkin 0a94e740d2 android: refactor modal views without navigation controller (#381)
* android: refactor modal views without navigation controller

* refactor navigation

* make alert manager global

* disable CRPendingSubSummary in terminal, hamburger menu instead of gear
2022-02-27 18:16:38 +00:00
IanRDavies 3f3a503def android: notifications (#369)
* minimal implementation of notifications and broken framework for background check for messages

* linting and need different id to have multiple messages

* working notification on new messages

* add autocancel to notifications

* add rudimentary linking to chat from notification

* group notifications from the same chat

* clarify comment

* revert to working version

* refactor

* minors

* two channels, silent and shouty

* rudimentary state control for notifications

* check if running in foreground

* more elegant solution to don't notify if in chat

* tidy up DisposableEffect use

* change message notification priority to high

* nuke opt-ins

* navigation via notification occasionally works with race condition (WIP)

* notification navigation is working; remove chat list/view from navigation; refactor ChatListNavLinkView

* group all simplex notifications, only show the latest message per chat, notification icons

* increase time to 30 sec

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-27 12:14:26 +00:00
Evgeny Poberezkin 0413865a3b ios, core: fix add contact screen, add logging, additional chat events (#380)
* ios, core: fix add contact screen, add logging, additional chat events

* fix alert dialogues

* fix precedence parsing error

* update alert messages
2022-02-26 20:21:32 +00:00
Efim Poberezkin 98268a95c2 Merge pull request #379 from simplex-chat/master (v1.3.0 terminal app) 2022-02-26 17:25:05 +04:00
Evgeny Poberezkin 1110a78e06 update versions/build #s: ios 0.4 (14), android 0.3 (4) 2022-02-26 13:10:47 +00:00
Evgeny Poberezkin 7cd43de5d5 Merge pull request #353 from simplex-chat/master
v1.2.1 terminal app
2022-02-22 19:28:17 +00:00
Evgeny Poberezkin 0f450fd9bf update readme (#314)
* update readme

* update README.md

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>

Co-authored-by: Efim Poberezkin <8711996+efim-poberezkin@users.noreply.github.com>
2022-02-16 13:00:27 +00:00
Efim Poberezkin ced8d2a45f Merge pull request #305 from simplex-chat/master (v1.2.0 terminal app) 2022-02-14 22:41:33 +04:00
Efim Poberezkin c59caa5d7f Merge pull request #292 from simplex-chat/master
v1.1.1 terminal app, v0.3 iOS app
2022-02-11 13:06:40 +04:00
85 changed files with 2539 additions and 1303 deletions
+90
View File
@@ -0,0 +1,90 @@
# SimpleX Chat Terms & Privacy Policy
SimpleX Chat is the first chat platform that is 100% private by design - not only it has no access to your messages (thanks to open-source double-ratchet end-to-end encryption protocol and additional encryption layers), it also has no access to your profile and contacts - we do not have access to your connections graph.
## Privacy Policy
SimpleX Chat Ltd. ("SimpleX Chat") uses the best industry practices for security and end-to-end encryption to provide secure end-to-end encrypted messaging via private connections. SimpleX Chat is built on top of SimpleX messaging and application platform that uses a new message routing protocol that allows establishing private connection without having any kind of addresses that identify its users - we don't use emails, phone numbers, usernames, identity keys or any other user identifiers to pass messages between the users.
### Information you provide
We do not store user profiles. The profile you create in the app is local to your device. When you create a user profile, no records are created on our servers, and we have no access to any part of your profile information, and even to the fact that you created a profile - it is a local record stored only on your device. That means that if you delete the app, and have no backup, you will permanently lose all the data and the private connections you create with other users.
Messages. SimpleX Chat cannot decrypt or otherwise access the content or size of your messages (each message is padded to a fixed size of 16kb). SimpleX Chat temporarily stores end-to-end encrypted messages on its servers for delivery to the devices that are temporarily offline. Your message history is stored only on your own devices.
Connections with other users. When you create a connection with another user, two messaging queues are created on our servers (we use separate queues for direct and response messages, that can be on two different servers), or on the servers that you configured in the app, in case it allows such configuration. At the time of updating this document only our terminal app allows configuring the servers, our mobile apps will allow such configuration in the near future. Our servers do not store information about which queues are linked to your profile on the device, and they do not have any information in common that allow us to establish that these queues are related to your device or your profile - the access to each queue is authorized by a set of unique encryption keys, different for each queue, and separate for sender and recipient of the messages that are transmitted through the queue.
Additional technical information can be stored on our servers, including randomly generated authentication tokens, keys, push tokens, and other material that is necessary to transmit messages. SimpleX Chat limits this additional technical information to the minimum required to operate the Services.
User Support. If you contact SimpleX Chat any personal data you may share with us is kept only for the purposes of researching the issue and contacting you about your case. We recommend contacting support via chat, when it is possible.
### Information we may share
We operate our Services using third parties. While we do not share any user data, these third party may access the encrypted user data as it is stored or transmitted via our servers.
We use Third party to provide email services - if you ask for support via email, your and SimpleX Chat email providers may access these emails according their privacy policies and terms of service.
The cases when SimpleX Chat may need to share the data we temporarily store on the servers:
- To meet any applicable law, regulation, legal process or enforceable governmental request.
- To enforce applicable Terms, including investigation of potential violations.
- To detect, prevent, or otherwise address fraud, security, or technical issues.
- To protect against harm to the rights, property, or safety of SimpleX Chat, our users, or the public as required or permitted by law.
### Updates
We will update this privacy policy as needed so that it is current, accurate, and as clear as possible. Your continued use of our Services confirms your acceptance of our updated Privacy Policy.
Please also read our Terms of Service.
If you have questions about our Privacy Policy please contact us at chat@simplex.chat.
## Terms of Service
You accept to our Terms of Service ("Terms") by installing or using any of our apps or services ("Services").
**Minimal age**. You must be at least 13 years old to use our Services. The minimum age to use our Services without parental approval may be higher in your country.
**Accessing the servers**. For the efficiency of the network access, the apps access all queues you create on any server via the same network (TCP/IP) connection. Our servers do not collect information about which queues were accessed via the same connection, so we do cannot establish which queues belong to the same users. Whoever might observe your network traffic would know which servers you use, and how much data you send, but not to whom it is sent - the data that leaves the servers is always different from the data they receive - there are no identifiers or cyphertext in common. Please refer to our [technical design document](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information about our privacy model and known security and privacy risks.
**Privacy of user data**. We do not retain any data we transmit for any longer than necessary to provide the Services. We only collect aggregate statistics across all users, not per users - we do not have information about how many people use SimpleX Chat (we only know an approximate number of app installations and the aggregate traffic through our servers). In any case, we do not and will not sell or in any way monetize user data.
**Operating our services**. For the purpose of operating our Services, you agree that your end-to-end encrypted messages are transferred via our servers in the United Kingdom, the United States and other countries where we have or use facilities and service providers or partners.
**Software**. You agree to downloading and installing updates to our Services when they are available; they would only be automatic if you configure your devices in this way.
**Traffic and device costs**. You are solely responsible for the traffic and device costs on which you use our Services, and any associated taxes.
**Legal and acceptable usage**. You agree to use our Services only for legal and acceptable purposes. You will not use (or assist others in using) our Services in ways that: 1) violate or infringe the rights of SimpleX Chat, our users, or others, including privacy, publicity, intellectual property, or other proprietary rights; 2) involve sending illegal or impermissible communications, e.g. spam.
**Damage to SimpleX Chat**. You must not (or assist others to) access, use, modify, distribute, transfer, or exploit our Services in unauthorized manners, or in ways that harm SimpleX Chat, our Services, or systems. For example, you must not 1) access our Services or systems without authorization, other than by using the apps; 2) disrupt the integrity or performance of our Services; 3) collect information about our users in any manner; or 4) sell, rent, or charge for our Services.
**Keeping your data secure**. SimpleX Chat is the first messaging platform that is 100% private by design - we neither have ability to access your messages, nor we have information about who you communicate with. That means that you are solely responsible for keeping your device and your user profile safe and secure. If you lose your phone or remove the app, you will not be able to recover the lost data, unless you made a back up.
**Storing the messages on the device**. Currently the messages are stored in the database on your device without encryption. It means that if you make a backup of the app and store it unecrypted, the backup provider may be able to access the messages.
**No Access to Emergency Services**. Our Services do not provide access to emergency service providers like the police, fire department, hospitals, or other public safety organizations. Make sure you can contact emergency service providers through a mobile, fixed-line telephone, or other service.
**Third-party services**. Our Services may allow you to access, use, or interact with third-party websites, apps, content, and other products and services. When you use third-party services, their terms and privacy policies govern your use of those services.
**Your Rights**. You own the mesasges and information you transmit through our Services. Your recipients are able to retain the messages you receive from you; there is no technical ability to delete data from their devices.
**License**. SimpleX Chat grants you a limited, revocable, non-exclusive, and non-transferable license to use our Services in accordance with these Terms. The source-code of services is available and can be used under [AGPL v3 licence](https://github.com/simplex-chat/simplex-chat/blob/stable/LICENSE)
**SimpleX Chat Rights**. We own all copyrights, trademarks, domains, logos, trade secrets, and other intellectual property rights associated with our Services. You may not use our copyrights, trademarks, domains, logos, and other intellectual property rights unless you have our written permission, and unless under an open-source license distributed together with the source code. To report copyright, trademark, or other intellectual property infringement, please contact chat@simplex.chat.
**Disclaimers**. YOU USE OUR SERVICES AT YOUR OWN RISK AND SUBJECT TO THE FOLLOWING DISCLAIMERS. WE PROVIDE OUR SERVICES ON AN “AS IS” BASIS WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, NON-INFRINGEMENT, AND FREEDOM FROM COMPUTER VIRUS OR OTHER HARMFUL CODE. SIMPLEX DOES NOT WARRANT THAT ANY INFORMATION PROVIDED BY US IS ACCURATE, COMPLETE, OR USEFUL, THAT OUR SERVICES WILL BE OPERATIONAL, ERROR-FREE, SECURE, OR SAFE, OR THAT OUR SERVICES WILL FUNCTION WITHOUT DISRUPTIONS, DELAYS, OR IMPERFECTIONS. WE DO NOT CONTROL, AND ARE NOT RESPONSIBLE FOR, CONTROLLING HOW OR WHEN OUR USERS USE OUR SERVICES. WE ARE NOT RESPONSIBLE FOR THE ACTIONS OR INFORMATION (INCLUDING CONTENT) OF OUR USERS OR OTHER THIRD PARTIES. YOU RELEASE US, AFFILIATES, DIRECTORS, OFFICERS, EMPLOYEES, PARTNERS, AND AGENTS ("SIMPLEX PARTIES") FROM ANY CLAIM, COMPLAINT, CAUSE OF ACTION, CONTROVERSY, OR DISPUTE (TOGETHER, "CLAIM") AND DAMAGES, KNOWN AND UNKNOWN, RELATING TO, ARISING OUT OF, OR IN ANY WAY CONNECTED WITH ANY SUCH CLAIM YOU HAVE AGAINST ANY THIRD PARTIES.
**Limitation of liability**. THE SIMPLEX PARTIES WILL NOT BE LIABLE TO YOU FOR ANY LOST PROFITS OR CONSEQUENTIAL, SPECIAL, PUNITIVE, INDIRECT, OR INCIDENTAL DAMAGES RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES, EVEN IF THE SIMPLEX PARTIES HAVE BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. OUR AGGREGATE LIABILITY RELATING TO, ARISING OUT OF, OR IN ANY WAY IN CONNECTION WITH OUR TERMS, US, OR OUR SERVICES WILL NOT EXCEED ONE DOLLAR ($1). THE FOREGOING DISCLAIMER OF CERTAIN DAMAGES AND LIMITATION OF LIABILITY WILL APPLY TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW. THE LAWS OF SOME JURISDICTIONS MAY NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO SOME OR ALL OF THE EXCLUSIONS AND LIMITATIONS SET FORTH ABOVE MAY NOT APPLY TO YOU. NOTWITHSTANDING ANYTHING TO THE CONTRARY IN OUR TERMS, IN SUCH CASES, THE LIABILITY OF THE SIMPLEX PARTIES WILL BE LIMITED TO THE EXTENT PERMITTED BY APPLICABLE LAW.
**Availability**. Our Services may be interrupted, including for maintenance, upgrades, or network or equipment failures. We may discontinue some or all of our Services, including certain features and the support for certain devices and platforms, at any time.
**Resolving disputes**. You agree to resolve any Claim you have with us relating to or arising from our Terms, us, or our Services in the courts of England and Wales. You also agree to submit to the personal jurisdiction of such courts for the purpose of resolving all such disputes. The laws of England govern our Terms, as well as any disputes, whether in court or arbitration, which might arise between SimpleX Chat and you, without regard to conflict of law provisions.
**Changes to the terms**. SimpleX Chat may update the Terms from time to time. Your continued use of our Services confirms your acceptance of our updated Terms and supersedes any prior Terms. You will comply with all applicable export control and trade sanctions laws. Our Terms cover the entire agreement between you and SimpleX Chat regarding our Services. If you do not agree with our Terms, you should stop using our Services.
**Enforcing the terms**. If we fail to enforce any of our Terms, that does not mean we waive the right to enforce them. If any provision of the Terms is deemed unlawful, void, or unenforceable, that provision shall be deemed severable from our Terms and shall not affect the enforceability of the remaining provisions. Our Services are not intended for distribution to or use in any country where such distribution or use would violate local law or would subject us to any regulations in another country. We reserve the right to limit our Services in any country. If you have specific questions about these Terms, please contact us at chat@simplex.chat.
**Ending these Terms**. You may end these Terms with SimpleX Chat at any time by deleting SimpleX Chat app(s) from your device and discontinuing use of our Services. The provisions related to Licenses, Disclaimers, Limitation of Liability, Resolving dispute, Availability, Changes to the terms, Enforcing the terms, and Ending these Terms will survive termination of your relationship with SimpleX Chat.
Updated March 1, 2022
+16 -8
View File
@@ -1,22 +1,30 @@
<img src="images/simplex-chat-logo.svg" alt="SimpleX logo" width="100%">
# SimpleX Chat
SimpleX - private and secure open-source chat and application platform - public beta for iOS now available!
# SimpleX - the first chat platform that is 100% private by design - it has no access to your connection graph!
[![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)
[![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 apps (both terminal UI and [iOS public beta](https://testflight.apple.com/join/DWuT2LQu)) use [SimpleXMQ](https://github.com/simplex-chat/simplexmq) message broker.
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apple_store.svg" alt="iOS app" height="42">](https://apps.apple.com/us/app/simplex-chat/id1605771084)
&nbsp;
[![Android app](https://github.com/simplex-chat/.github/blob/master/profile/images/google_play.svg)](https://play.google.com/store/apps/details?id=chat.simplex.app)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/testflight.png" alt="iOS TestFlight" height="41">](https://testflight.apple.com/join/DWuT2LQu)
&nbsp;
[<img src="https://github.com/simplex-chat/.github/blob/master/profile/images/apk_icon.png" alt="iOS TestFlight" height="41">](https://github.com/simplex-chat/website/raw/master/simplex.apk)
- 🖲 Protects your messages and metadata - who you talk to and when.
- 🔐 Double ratchet encryption.
- 📱 Mobile apps for Android ([Google Play](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK](https://github.com/simplex-chat/website/raw/master/simplex.apk)) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084). [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/master/blog/20220308-simplex-chat-mobile-apps.md).
- 🚀 [TestFlight preview for iOS](https://testflight.apple.com/join/DWuT2LQu) with the new features 1-2 weeks earlier - **limited to 10,000 users**!
- 🖥 Available as a [terminal (console) app / CLI](https://github.com/simplex-chat/simplex-chat) on Linux, MacOS, Windows.
See [SimpleX overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md) for more information on platform objectives and technical design.
***SimpleX Chat [public beta for iOS 15 is available via TestFlight](https://testflight.apple.com/join/DWuT2LQu)** - it will help us a lot if you test it! [See the announcement here](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220214-simplex-chat-ios-public-beta.md).*
### :zap: Quick installation
### :zap: Quick installation of a terminal app
```sh
curl -o- https://raw.githubusercontent.com/simplex-chat/simplex-chat/master/install.sh | bash
+14 -3
View File
@@ -9,10 +9,10 @@ android {
defaultConfig {
applicationId "chat.simplex.app"
minSdk 26
minSdk 29
targetSdk 32
versionCode 3
versionName "0.2"
versionCode 11
versionName "1.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -40,6 +40,12 @@ android {
}
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
freeCompilerArgs += "-opt-in=androidx.compose.ui.text.ExperimentalTextApi"
freeCompilerArgs += "-opt-in=androidx.compose.material.ExperimentalMaterialApi"
freeCompilerArgs += "-opt-in=com.google.accompanist.insets.ExperimentalAnimatedInsets"
freeCompilerArgs += "-opt-in=com.google.accompanist.permissions.ExperimentalPermissionsApi"
freeCompilerArgs += "-opt-in=kotlinx.serialization.InternalSerializationApi"
}
externalNativeBuild {
cmake {
@@ -66,6 +72,7 @@ dependencies {
implementation "androidx.compose.material:material:$compose_version"
implementation "androidx.compose.ui:ui-tooling-preview:$compose_version"
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.1'
implementation 'androidx.lifecycle:lifecycle-process:2.4.1'
implementation 'androidx.activity:activity-compose:1.4.0'
implementation 'org.jetbrains.kotlinx:kotlinx-datetime:0.3.2'
implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2'
@@ -73,6 +80,10 @@ dependencies {
implementation "androidx.navigation:navigation-compose:2.4.1"
implementation "com.google.accompanist:accompanist-insets:0.23.0"
def work_version = "2.7.1"
implementation "androidx.work:work-runtime-ktx:$work_version"
implementation "androidx.work:work-multiprocess:$work_version"
def camerax_version = "1.1.0-beta01"
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
@@ -2,180 +2,119 @@ package chat.simplex.app
import android.app.Application
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.Modifier
import androidx.lifecycle.AndroidViewModel
import androidx.navigation.*
import androidx.navigation.compose.*
import chat.simplex.app.model.*
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.*
import chat.simplex.app.views.chat.ChatInfoView
import chat.simplex.app.views.SplashView
import chat.simplex.app.views.WelcomeView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.*
import chat.simplex.app.views.usersettings.*
import com.google.accompanist.insets.ExperimentalAnimatedInsets
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.serialization.decodeFromString
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
//import kotlinx.serialization.decodeFromString
class MainActivity: ComponentActivity() {
private val vm by viewModels<SimplexViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// testJson()
connectIfOpenedViaUri(intent, vm.chatModel)
processIntent(intent, vm.chatModel)
// vm.app.initiateBackgroundWork()
setContent {
SimpleXTheme {
Navigation(vm.chatModel)
}
}
}
}
@DelicateCoroutinesApi
class SimplexViewModel(application: Application): AndroidViewModel(application) {
val chatModel = getApplication<SimplexApp>().chatModel
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun MainPage(chatModel: ChatModel, nav: NavController) {
when (chatModel.userCreated.value) {
null -> SplashView()
false -> WelcomeView(chatModel) { nav.navigate(Pages.ChatList.route) }
true -> ChatListView(chatModel, nav)
}
}
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun Navigation(chatModel: ChatModel) {
val nav = rememberNavController()
Box {
NavHost(navController = nav, startDestination = Pages.Home.route) {
composable(route = Pages.Home.route) {
MainPage(chatModel, nav)
}
composable(route = Pages.Welcome.route) {
WelcomeView(chatModel) {
nav.navigate(Pages.Home.route) {
popUpTo(Pages.Home.route) { inclusive = true }
}
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
MainPage(vm.chatModel)
}
}
composable(route = Pages.ChatList.route) {
ChatListView(chatModel, nav)
}
composable(route = Pages.Chat.route) {
ChatView(chatModel, nav)
}
composable(route = Pages.AddContact.route) {
AddContactView(chatModel, nav)
}
composable(route = Pages.Connect.route) {
ConnectContactView(chatModel, nav)
}
composable(route = Pages.ChatInfo.route) {
ChatInfoView(chatModel, nav)
}
composable(route = Pages.Terminal.route) {
TerminalView(chatModel, nav)
}
composable(
Pages.TerminalItemDetails.route + "/{identifier}",
arguments = listOf(
navArgument("identifier") {
type = NavType.LongType
}
)
) { entry -> DetailView(entry.arguments!!.getLong("identifier"), chatModel.terminalItems, nav) }
composable(route = Pages.UserProfile.route) {
UserProfileView(chatModel, nav)
}
composable(route = Pages.UserAddress.route) {
UserAddressView(chatModel, nav)
}
composable(route = Pages.Help.route) {
HelpView(chatModel, nav)
}
composable(route = Pages.Markdown.route) {
MarkdownHelpView(nav)
}
}
val am = chatModel.alertManager
if (am.presentAlert.value) am.alertView.value?.invoke()
}
}
sealed class Pages(val route: String) {
object Home: Pages("home")
object Terminal: Pages("terminal")
object Welcome: Pages("welcome")
object TerminalItemDetails: Pages("details")
object ChatList: Pages("chats")
object Chat: Pages("chat")
object AddContact: Pages("add_contact")
object Connect: Pages("connect")
object ChatInfo: Pages("chat_info")
object UserProfile: Pages("user_profile")
object UserAddress: Pages("user_address")
object Help: Pages("help")
object Markdown: Pages("markdown")
}
@DelicateCoroutinesApi
fun connectIfOpenedViaUri(intent: Intent?, chatModel: ChatModel) {
val uri = intent?.data
if (intent?.action == "android.intent.action.VIEW" && uri != null) {
Log.d("SIMPLEX", "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
chatModel.appOpenUrl.value = uri
} else {
withUriAction(chatModel, uri) { action ->
chatModel.alertManager.showAlertMsg(
title = "Connect via $action link?",
text = "Your profile will be sent to the contact that you received this link from.",
confirmText = "Connect",
onConfirm = {
withApi {
Log.d("SIMPLEX", "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, action, uri)
}
}
)
}
}
}
}
fun testJson() {
val str = """
{}
""".trimIndent()
println(json.decodeFromString<ChatItem>(str))
class SimplexViewModel(application: Application): AndroidViewModel(application) {
val app = getApplication<SimplexApp>()
val chatModel = app.chatModel
}
@Composable
fun MainPage(chatModel: ChatModel) {
Box {
when (chatModel.userCreated.value) {
null -> SplashView()
false -> WelcomeView(chatModel)
true ->
if (chatModel.chatId.value == null) ChatListView(chatModel)
else ChatView(chatModel)
}
ModalManager.shared.showInView()
AlertManager.shared.showInView()
}
}
fun processIntent(intent: Intent?, chatModel: ChatModel) {
when (intent?.action) {
NtfManager.OpenChatAction -> {
val chatId = intent.getStringExtra("chatId")
Log.d(TAG, "processIntent: OpenChatAction $chatId")
if (chatId != null) {
val cInfo = chatModel.getChat(chatId)?.chatInfo
if (cInfo != null) withApi { openChat(chatModel, cInfo) }
}
}
"android.intent.action.VIEW" -> {
val uri = intent.data
if (uri != null) connectIfOpenedViaUri(uri, chatModel)
}
}
}
fun connectIfOpenedViaUri(uri: Uri, chatModel: ChatModel) {
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
if (chatModel.currentUser.value == null) {
// TODO open from chat list view
chatModel.appOpenUrl.value = uri
} else {
withUriAction(uri) { action ->
AlertManager.shared.showAlertMsg(
title = "Connect via $action link?",
text = "Your profile will be sent to the contact that you received this link from.",
confirmText = "Connect",
onConfirm = {
withApi {
Log.d(TAG, "connectIfOpenedViaUri: connecting")
connectViaUri(chatModel, action, uri)
}
}
)
}
}
}
//fun testJson() {
// val str = """
// {}
// """.trimIndent()
//
// println(json.decodeFromString<ChatItem>(str))
//}
@@ -3,19 +3,16 @@ package chat.simplex.app
import android.app.Application
import android.net.LocalServerSocket
import android.util.Log
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.model.ChatController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.*
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
import java.io.BufferedReader
import java.io.InputStreamReader
import java.util.*
import java.util.concurrent.Semaphore
import kotlin.concurrent.thread
const val TAG = "SIMPLEX"
// ghc's rts
external fun initHS()
// android-support
@@ -27,15 +24,18 @@ external fun chatInit(path: String): ChatCtrl
external fun chatSendCmd(ctrl: ChatCtrl, msg: String) : String
external fun chatRecvMsg(ctrl: ChatCtrl) : String
@DelicateCoroutinesApi
//class SimplexApp: Application(), LifecycleEventObserver {
class SimplexApp: Application() {
private lateinit var controller: ChatController
lateinit var chatModel: ChatModel
private lateinit var ntfManager: NtfManager
override fun onCreate() {
super.onCreate()
// ProcessLifecycleOwner.get().lifecycle.addObserver(this)
ntfManager = NtfManager(applicationContext)
val ctrl = chatInit(applicationContext.filesDir.toString())
controller = ChatController(ctrl, AlertManager())
controller = ChatController(ctrl, ntfManager, applicationContext)
chatModel = controller.chatModel
withApi {
val user = controller.apiGetActiveUser()
@@ -43,71 +43,13 @@ class SimplexApp: Application() {
}
}
class AlertManager {
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
var presentAlert = mutableStateOf<Boolean>(false)
fun showAlert(alert: @Composable () -> Unit) {
Log.d("SIMPLEX", "AlertManager.showAlert")
alertView.value = alert
presentAlert.value = true
}
fun hideAlert() {
presentAlert.value = false
alertView.value = null
}
fun showAlertDialog(
title: String,
text: String? = null,
confirmText: String = "Ok",
onConfirm: (() -> Unit)? = null,
dismissText: String = "Cancel",
onDismiss: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
},
dismissButton = {
Button(onClick = {
onDismiss?.invoke()
hideAlert()
}) { Text(dismissText) }
}
)
}
}
fun showAlertMsg(
title: String, text: String? = null,
confirmText: String = "Ok", onConfirm: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
}
)
}
}
}
// override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
// Log.d(TAG, "onStateChanged: $event")
// if (event == Lifecycle.Event.ON_STOP) {
// Log.e(TAG, "BGManager schedule ${Clock.System.now()}")
// BGManager.schedule(applicationContext)
// }
// }
companion object {
init {
@@ -115,12 +57,12 @@ class SimplexApp: Application() {
val s = Semaphore(0)
thread(name="stdout/stderr pipe") {
Log.d("SIMPLEX", "starting server")
Log.d(TAG, "starting server")
val server = LocalServerSocket(socketName)
Log.d("SIMPLEX", "started server")
Log.d(TAG, "started server")
s.release()
val receiver = server.accept()
Log.d("SIMPLEX", "started receiver")
Log.d(TAG, "started receiver")
val logbuffer = FifoQueue<String>(500)
if (receiver != null) {
val inStream = receiver.inputStream
@@ -129,7 +71,7 @@ class SimplexApp: Application() {
while(true) {
val line = input.readLine() ?: break
Log.d("SIMPLEX (stdout/stderr)", line)
Log.w("$TAG (stdout/stderr)", line)
logbuffer.add(line)
}
}
@@ -0,0 +1,46 @@
package chat.simplex.app.model
import android.content.Context
import android.util.Log
import androidx.work.*
import chat.simplex.app.TAG
import chat.simplex.app.chatRecvMsg
import kotlinx.datetime.Clock
import java.time.Duration
import java.util.concurrent.TimeUnit
class BGManager(appContext: Context, workerParams: WorkerParameters): //, ctrl: ChatCtrl):
Worker(appContext, workerParams) {
// val controller = ctrl
init {}
override fun doWork(): Result {
Log.e(TAG, "BGManager doWork ${Clock.System.now()}")
schedule(applicationContext)
getNewItems()
return Result.success()
}
private fun getNewItems() {
Log.e(TAG, "BGManager getNewItems")
// val json = chatRecvMsg(controller)
// val r = APIResponse.decodeStr(json).resp
// Log.d(TAG, "chatRecvMsg: ${r.responseType}")
}
companion object {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
fun schedule(appContext: Context) {
val request = OneTimeWorkRequestBuilder<BGManager>()
.setInitialDelay(Duration.ofMinutes(10))
.setConstraints(constraints)
.build()
WorkManager.getInstance(appContext)
.enqueue(request)
}
}
}
@@ -7,25 +7,26 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.app.SimplexApp
import chat.simplex.app.ui.theme.*
import kotlinx.coroutines.DelicateCoroutinesApi
import chat.simplex.app.ui.theme.SecretColor
import chat.simplex.app.ui.theme.SimplexBlue
import kotlinx.datetime.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.*
@DelicateCoroutinesApi
class ChatModel(val controller: ChatController, val alertManager: SimplexApp.AlertManager) {
class ChatModel(val controller: ChatController) {
var currentUser = mutableStateOf<User?>(null)
var userCreated = mutableStateOf<Boolean?>(null)
var chats = mutableStateListOf<Chat>()
var chatsLoaded = mutableStateOf<Boolean?>(null)
var chatId = mutableStateOf<String?>(null)
var chatItems = mutableStateListOf<ChatItem>()
var connReqInvitation: String? = null
var terminalItems = mutableStateListOf<TerminalItem>()
var userAddress = mutableStateOf<String?>(null)
var userSMPServers = mutableStateOf<(List<String>)?>(null)
// set when app is opened via contact or invitation URI
var appOpenUrl = mutableStateOf<Uri?>(null)
@@ -590,14 +591,57 @@ sealed class CIContent {
}
}
@Serializable
@Serializable(with = MsgContentSerializer::class)
sealed class MsgContent {
abstract val text: String
abstract val cmdString: String
@Serializable @SerialName("text")
class MCText(override val text: String): MsgContent() {
override val cmdString get() = "text $text"
class MCText(override val text: String): MsgContent()
class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent()
val cmdString: String get() = when (this) {
is MCText -> "text $text"
is MCUnknown -> "json $json"
}
}
object MsgContentSerializer : KSerializer<MsgContent> {
override val descriptor: SerialDescriptor = buildSerialDescriptor("MsgContent", PolymorphicKind.SEALED) {
element("MCText", buildClassSerialDescriptor("MCText") {
element<String>("text")
})
element("MCUnknown", buildClassSerialDescriptor("MCUnknown"))
}
override fun deserialize(decoder: Decoder): MsgContent {
require(decoder is JsonDecoder)
val json = decoder.decodeJsonElement()
return if (json is JsonObject) {
if ("type" in json) {
val t = json["type"]?.jsonPrimitive?.content ?: ""
val text = json["text"]?.jsonPrimitive?.content ?: "unknown message format"
when (t) {
"text" -> MsgContent.MCText(text)
else -> MsgContent.MCUnknown(t, text, json)
}
} else {
MsgContent.MCUnknown(text = "invalid message format", json = json)
}
} else {
MsgContent.MCUnknown(text = "invalid message format", json = json)
}
}
override fun serialize(encoder: Encoder, value: MsgContent) {
require(encoder is JsonEncoder)
val json = when (value) {
is MsgContent.MCText ->
buildJsonObject {
put("type", "text")
put("text", value.text)
}
is MsgContent.MCUnknown -> value.json
}
encoder.encodeJsonElement(json)
}
}
@@ -654,7 +698,7 @@ enum class FormatColor(val color: String) {
val uiColor: Color @Composable get() = when (this) {
red -> Color.Red
green -> Color.Green
blue -> Color.Blue
blue -> SimplexBlue
yellow -> Color.Yellow
cyan -> Color.Cyan
magenta -> Color.Magenta
@@ -0,0 +1,77 @@
package chat.simplex.app.model
import android.app.*
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
import kotlinx.datetime.Clock
class NtfManager(val context: Context) {
companion object {
const val MessageChannel: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
const val MessageGroup: String = "chat.simplex.app.MESSAGE_NOTIFICATION"
const val OpenChatAction: String = "chat.simplex.app.OPEN_CHAT"
}
private val manager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private var prevNtfTime = mutableMapOf<String, Long>()
private val msgNtfTimeoutMs = 30000L
init {
manager.createNotificationChannel(NotificationChannel(
MessageChannel,
"SimpleX Chat messages",
NotificationManager.IMPORTANCE_HIGH
))
}
fun notifyMessageReceived(cInfo: ChatInfo, cItem: ChatItem) {
Log.d(TAG, "notifyMessageReceived ${cInfo.id}")
val now = Clock.System.now().toEpochMilliseconds()
val recentNotification = (now - prevNtfTime.getOrDefault(cInfo.id, 0) < msgNtfTimeoutMs)
prevNtfTime[cInfo.id] = now
val notification = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(cInfo.displayName)
.setContentText(cItem.content.text)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(cInfo))
.setSilent(recentNotification)
.build()
val summary = NotificationCompat.Builder(context, MessageChannel)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setGroupSummary(true)
.build()
with(NotificationManagerCompat.from(context)) {
// using cInfo.id only shows one notification per chat and updates it when the message arrives
notify(cInfo.id.hashCode(), notification)
notify(0, summary)
}
}
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
val intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra("chatId", cInfo.id)
.setAction(OpenChatAction)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
getPendingIntent(uniqueInt, PendingIntent.FLAG_IMMUTABLE)
}
}
}
@@ -1,10 +1,15 @@
package chat.simplex.app.model
import android.app.ActivityManager
import android.app.ActivityManager.RunningAppProcessInfo
import android.content.Context
import android.util.Log
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.*
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
@@ -14,23 +19,22 @@ import kotlin.concurrent.thread
typealias ChatCtrl = Long
@DelicateCoroutinesApi
open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.AlertManager) {
var chatModel = ChatModel(this, alertManager)
open class ChatController(val ctrl: ChatCtrl, val ntfManager: NtfManager, val appContext: Context) {
var chatModel = ChatModel(this)
suspend fun startChat(u: User) {
chatModel.currentUser = mutableStateOf(u)
chatModel.userCreated.value = true
Log.d("SIMPLEX (user)", u.toString())
Log.d(TAG, "user: $u")
try {
apiStartChat()
chatModel.userAddress.value = apiGetUserAddress()
chatModel.userSMPServers.value = getUserSMPServers()
chatModel.chats.addAll(apiGetChats())
chatModel.chatsLoaded.value = true
chatModel.currentUser = mutableStateOf(u)
chatModel.userCreated.value = true
startReceiver()
Log.d("SIMPLEX", "started chat")
Log.d(TAG, "started chat")
} catch(e: Error) {
Log.d("SIMPLEX", "failed starting chat $e")
Log.e(TAG, "failed starting chat $e")
throw e
}
}
@@ -41,16 +45,28 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
}
}
open fun isAppOnForeground(context: Context): Boolean {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val appProcesses = activityManager.runningAppProcesses ?: return false
val packageName = context.packageName
for (appProcess in appProcesses) {
if (appProcess.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND && appProcess.processName == packageName) {
return true
}
}
return false
}
suspend fun sendCmd(cmd: CC): CR {
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
chatModel.terminalItems.add(TerminalItem.cmd(cmd))
val json = chatSendCmd(ctrl, c)
Log.d("SIMPLEX", "sendCmd: ${cmd.cmdType}")
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
val r = APIResponse.decodeStr(json)
Log.d("SIMPLEX", "sendCmd response type ${r.resp.responseType}")
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d("SIMPLEX", "sendCmd response json $json")
Log.d(TAG, "sendCmd response json $json")
}
chatModel.terminalItems.add(TerminalItem.resp(r.resp))
r.resp
@@ -61,8 +77,8 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
return withContext(Dispatchers.IO) {
val json = chatRecvMsg(ctrl)
val r = APIResponse.decodeStr(json).resp
Log.d("SIMPLEX", "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d("SIMPLEX", "chatRecvMsg json: $json")
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
r
}
}
@@ -75,7 +91,7 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
suspend fun apiGetActiveUser(): User? {
val r = sendCmd(CC.ShowActiveUser())
if (r is CR.ActiveUser) return r.user
Log.d("SIMPLEX", "apiGetActiveUser: ${r.responseType} ${r.details}")
Log.d(TAG, "apiGetActiveUser: ${r.responseType} ${r.details}")
chatModel.userCreated.value = false
return null
}
@@ -83,7 +99,7 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
suspend fun apiCreateActiveUser(p: Profile): User {
val r = sendCmd(CC.CreateActiveUser(p))
if (r is CR.ActiveUser) return r.user
Log.d("SIMPLEX", "apiCreateActiveUser: ${r.responseType} ${r.details}")
Log.d(TAG, "apiCreateActiveUser: ${r.responseType} ${r.details}")
throw Error("user not created ${r.responseType} ${r.details}")
}
@@ -102,21 +118,43 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
suspend fun apiGetChat(type: ChatType, id: Long): Chat? {
val r = sendCmd(CC.ApiGetChat(type, id))
if (r is CR.ApiChat ) return r.chat
Log.d("SIMPLEX", "apiGetChat bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiGetChat bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiSendMessage(type: ChatType, id: Long, mc: MsgContent): AChatItem? {
val r = sendCmd(CC.ApiSendMessage(type, id, mc))
if (r is CR.NewChatItem ) return r.chatItem
Log.d("SIMPLEX", "apiSendMessage bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiSendMessage bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun getUserSMPServers(): List<String>? {
val r = sendCmd(CC.GetUserSMPServers())
if (r is CR.UserSMPServers) return r.smpServers
Log.e(TAG, "getUserSMPServers bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun setUserSMPServers(smpServers: List<String>): Boolean {
val r = sendCmd(CC.SetUserSMPServers(smpServers))
return when (r) {
is CR.CmdOk -> true
else -> {
Log.e(TAG, "setUserSMPServers bad response: ${r.responseType} ${r.details}")
AlertManager.shared.showAlertMsg(
"Error saving SMP servers",
"Make sure SMP server addresses are in correct format, line separated and are not duplicated"
)
false
}
}
}
suspend fun apiAddContact(): String? {
val r = sendCmd(CC.AddContact())
if (r is CR.Invitation) return r.connReqInvitation
Log.d("SIMPLEX", "apiAddContact bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiAddContact bad response: ${r.responseType} ${r.details}")
return null
}
@@ -125,14 +163,14 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
when {
r is CR.SentConfirmation || r is CR.SentInvitation -> return true
r is CR.ContactAlreadyExists -> {
alertManager.showAlertMsg("Contact already exists",
AlertManager.shared.showAlertMsg("Contact already exists",
"You are already connected to ${r.contact.displayName} via this link"
)
return false
}
r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorChat
&& r.chatError.errorType is ChatErrorType.InvalidConnReq -> {
alertManager.showAlertMsg("Invalid connection link",
AlertManager.shared.showAlertMsg("Invalid connection link",
"Please check that you used the correct link or ask your contact to send you another one."
)
return false
@@ -146,20 +184,19 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean {
val r = sendCmd(CC.ApiDeleteChat(type, id))
when {
r is CR.ContactDeleted -> return true // TODO groups
r is CR.ChatCmdError -> {
when (r) {
is CR.ContactDeleted -> return true // TODO groups
is CR.ChatCmdError -> {
val e = r.chatError
if (e is ChatError.ChatErrorChat && e.errorType is ChatErrorType.ContactGroups) {
alertManager.showAlertMsg(
AlertManager.shared.showAlertMsg(
"Can't delete contact!",
"Contact ${e.errorType.contact.displayName} cannot be deleted, it is a member of the group(s) ${e.errorType.groupNames}"
)
return false
}
}
else -> apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r)
}
apiErrorAlert("apiDeleteChat", "Error deleting ${type.chatTypeName}", r)
return false
}
@@ -167,21 +204,21 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
val r = sendCmd(CC.UpdateProfile(profile))
if (r is CR.UserProfileNoChange) return profile
if (r is CR.UserProfileUpdated) return r.toProfile
Log.d("SIMPLEX", "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiCreateUserAddress(): String? {
val r = sendCmd(CC.CreateMyAddress())
if (r is CR.UserContactLinkCreated) return r.connReqContact
Log.d("SIMPLEX", "apiCreateUserAddress bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiCreateUserAddress bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiDeleteUserAddress(): Boolean {
val r = sendCmd(CC.DeleteMyAddress())
if (r is CR.UserContactLinkDeleted) return true
Log.d("SIMPLEX", "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiDeleteUserAddress bad response: ${r.responseType} ${r.details}")
return false
}
@@ -192,35 +229,35 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
&& r.chatError.storeError is StoreError.UserContactLinkNotFound) {
return null
}
Log.d("SIMPLEX", "apiGetUserAddress bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiGetUserAddress bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiAcceptContactRequest(contactReqId: Long): Contact? {
val r = sendCmd(CC.ApiAcceptContact(contactReqId))
if (r is CR.AcceptingContactRequest) return r.contact
Log.d("SIMPLEX", "apiAcceptContactRequest bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiAcceptContactRequest bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiRejectContactRequest(contactReqId: Long): Boolean {
val r = sendCmd(CC.ApiRejectContact(contactReqId))
if (r is CR.ContactRequestRejected) return true
Log.d("SIMPLEX", "apiRejectContactRequest bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiRejectContactRequest bad response: ${r.responseType} ${r.details}")
return false
}
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
val r = sendCmd(CC.ApiChatRead(type, id, range))
if (r is CR.CmdOk) return true
Log.d("SIMPLEX", "apiChatRead bad response: ${r.responseType} ${r.details}")
Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}")
return false
}
fun apiErrorAlert(method: String, title: String, r: CR) {
val errMsg = "${r.responseType}: ${r.details}"
Log.e("SIMPLEX", "$method bad response: $errMsg")
alertManager.showAlertMsg(title, errMsg)
Log.e(TAG, "$method bad response: $errMsg")
AlertManager.shared.showAlertMsg(title, errMsg)
}
fun processReceivedMsg(r: CR) {
@@ -260,7 +297,9 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
chatModel.addChatItem(cInfo, cItem)
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
if (!isAppOnForeground(appContext) || chatModel.chatId.value != cInfo.id) {
ntfManager.notifyMessageReceived(cInfo, cItem)
}
}
// case let .chatItemUpdated(aChatItem):
// let cInfo = aChatItem.chatInfo
@@ -268,9 +307,8 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
// if chatModel.upsertChatItem(cInfo, cItem) {
// NtfManager.shared.notifyMessageReceived(cInfo, cItem)
// }
// default:
// logger.debug("unsupported event: \(res.responseType)")
// }
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
}
@@ -305,6 +343,8 @@ sealed class CC {
class ApiGetChats: CC()
class ApiGetChat(val type: ChatType, val id: Long): CC()
class ApiSendMessage(val type: ChatType, val id: Long, val mc: MsgContent): CC()
class GetUserSMPServers(): CC()
class SetUserSMPServers(val smpServers: List<String>): CC()
class AddContact: CC()
class Connect(val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC()
@@ -324,6 +364,8 @@ sealed class CC {
is ApiGetChats -> "/_get chats"
is ApiGetChat -> "/_get chat ${chatRef(type, id)} count=100"
is ApiSendMessage -> "/_send ${chatRef(type, id)} ${mc.cmdString}"
is GetUserSMPServers -> "/smp_servers"
is SetUserSMPServers -> "/smp_servers ${smpServersStr(smpServers)}"
is AddContact -> "/connect"
is Connect -> "/connect $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}"
@@ -344,6 +386,8 @@ sealed class CC {
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
@@ -360,6 +404,8 @@ sealed class CC {
companion object {
fun chatRef(chatType: ChatType, id: Long) = "${chatType.type}${id}"
fun smpServersStr(smpServers: List<String>) = if (smpServers.isEmpty()) "default" else smpServers.joinToString(separator = ",")
}
}
@@ -394,8 +440,10 @@ class APIResponse(val resp: CR, val corr: String? = null) {
sealed class CR {
@Serializable @SerialName("activeUser") class ActiveUser(val user: User): CR()
@Serializable @SerialName("chatStarted") class ChatStarted: CR()
@Serializable @SerialName("chatRunning") class ChatRunning: CR()
@Serializable @SerialName("apiChats") class ApiChats(val chats: List<Chat>): CR()
@Serializable @SerialName("apiChat") class ApiChat(val chat: Chat): CR()
@Serializable @SerialName("userSMPServers") class UserSMPServers(val smpServers: List<String>): CR()
@Serializable @SerialName("invitation") class Invitation(val connReqInvitation: String): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation: CR()
@Serializable @SerialName("sentInvitation") class SentInvitation: CR()
@@ -430,8 +478,10 @@ sealed class CR {
val responseType: String get() = when(this) {
is ActiveUser -> "activeUser"
is ChatStarted -> "chatStarted"
is ChatRunning -> "chatRunning"
is ApiChats -> "apiChats"
is ApiChat -> "apiChats"
is ApiChat -> "apiChat"
is UserSMPServers -> "userSMPServers"
is Invitation -> "invitation"
is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation"
@@ -467,8 +517,10 @@ sealed class CR {
val details: String get() = when(this) {
is ActiveUser -> json.encodeToString(user)
is ChatStarted -> noDetails()
is ChatRunning -> noDetails()
is ApiChats -> json.encodeToString(chats)
is ApiChat -> json.encodeToString(chat)
is UserSMPServers -> json.encodeToString(smpServers)
is Invitation -> connReqInvitation
is SentConfirmation -> noDetails()
is SentInvitation -> noDetails()
@@ -21,12 +21,12 @@ val Typography = Typography(
h3 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 20.sp
fontSize = 19.sp
),
body1 = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 18.sp
fontSize = 17.sp
),
body2 = TextStyle(
fontFamily = FontFamily.Default,
@@ -1,22 +1,18 @@
package chat.simplex.app.views
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@Composable
fun SplashView() {
Box(modifier = Modifier
.fillMaxSize()
.background(color = Color.White)
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
// Image(
// painter = painterResource(R.drawable.logo),
@@ -1,8 +1,10 @@
package chat.simplex.app.views
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
@@ -15,21 +17,20 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.SendMsgView
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
import chat.simplex.app.views.newchat.ModalManager
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.launch
@DelicateCoroutinesApi
@Composable
fun TerminalView(chatModel: ChatModel, nav: NavController) {
TerminalLayout(chatModel.terminalItems, nav::popBackStack, nav::navigate) { cmd ->
fun TerminalView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
TerminalLayout(chatModel.terminalItems, close) { cmd ->
withApi {
// show "in progress"
chatModel.controller.sendCmd(CC.Console(cmd))
@@ -39,39 +40,45 @@ fun TerminalView(chatModel: ChatModel, nav: NavController) {
}
@Composable
fun TerminalLayout(terminalItems: List<TerminalItem> , close: () -> Unit, navigate: (String) -> Unit,
sendCommand: (String) -> Unit) {
fun TerminalLayout(terminalItems: List<TerminalItem> , close: () -> Unit, sendCommand: (String) -> Unit) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { CloseSheetBar(close) },
bottomBar = { SendMsgView(sendCommand) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(
Surface(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
TerminalLog(terminalItems, navigate)
TerminalLog(terminalItems)
}
}
}
}
@Composable
fun TerminalLog(terminalItems: List<TerminalItem>, navigate: (String) -> Unit) {
fun TerminalLog(terminalItems: List<TerminalItem>) {
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
LazyColumn(state = listState) {
items(terminalItems) { item ->
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable { navigate("details/${item.id}") })
Text("${item.date.toString().subSequence(11, 19)} ${item.label}",
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 18.sp, color = MaterialTheme.colors.primary),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 8.dp, vertical = 4.dp)
.clickable {
ModalManager.shared.showModal {
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text(item.details)
}
}
}
)
}
val len = terminalItems.count()
if (len > 1) {
@@ -82,22 +89,6 @@ fun TerminalLog(terminalItems: List<TerminalItem>, navigate: (String) -> Unit) {
}
}
@Composable
fun DetailView(identifier: Long, terminalItems: List<TerminalItem>, nav: NavController){
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Column {
CloseSheetBar(nav::popBackStack)
SelectionContainer(modifier = Modifier.verticalScroll(rememberScrollState())) {
Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "")
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -110,7 +101,6 @@ fun PreviewTerminalLayout() {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
close = {},
navigate = {},
sendCommand = {}
)
}
@@ -19,11 +19,9 @@ import chat.simplex.app.model.Profile
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun WelcomeView(chatModel: ChatModel, routeHome: () -> Unit) {
fun WelcomeView(chatModel: ChatModel) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
@@ -60,7 +58,7 @@ fun WelcomeView(chatModel: ChatModel, routeHome: () -> Unit) {
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(24.dp))
CreateProfilePanel(chatModel, routeHome)
CreateProfilePanel(chatModel)
}
}
}
@@ -71,9 +69,8 @@ fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null
}
@DelicateCoroutinesApi
@Composable
fun CreateProfilePanel(chatModel: ChatModel, routeHome: () -> Unit) {
fun CreateProfilePanel(chatModel: ChatModel) {
var displayName by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
@@ -154,10 +151,9 @@ fun CreateProfilePanel(chatModel: ChatModel, routeHome: () -> Unit) {
Profile(displayName, fullName)
)
chatModel.controller.startChat(user)
routeHome()
}
},
enabled = displayName.isNotEmpty()
) { Text("Create")}
enabled = (displayName.isNotEmpty() && isValidDisplayName(displayName))
) { Text("Create") }
}
}
}
@@ -1,5 +1,6 @@
package chat.simplex.app.views.chat
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -14,22 +15,19 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.*
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun ChatInfoView(chatModel: ChatModel, nav: NavController) {
fun ChatInfoView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
val chat = chatModel.chats.firstOrNull { it.id == chatModel.chatId.value }
if (chat != null) {
ChatInfoLayout(chat,
close = { nav.popBackStack() },
close = close,
deleteContact = {
chatModel.alertManager.showAlertMsg(
AlertManager.shared.showAlertMsg(
title = "Delete contact?",
text = "Contact and all messages will be deleted - this cannot be undone!",
confirmText = "Delete",
@@ -39,7 +37,8 @@ fun ChatInfoView(chatModel: ChatModel, nav: NavController) {
val r = chatModel.controller.apiDeleteChat(cInfo.chatType, cInfo.apiId)
if (r) {
chatModel.removeChat(cInfo.id)
nav.navigate(Pages.ChatList.route)
chatModel.chatId.value = null
close()
}
}
}
@@ -94,7 +93,7 @@ fun ChatInfoLayout(chat: Chat, close: () -> Unit, deleteContact: () -> Unit) {
Spacer(Modifier.weight(1F))
Box(Modifier.padding(24.dp)) {
Box(Modifier.padding(48.dp)) {
SimpleButton(
"Delete contact", icon = Icons.Outlined.Delete,
color = Color.Red,
@@ -1,6 +1,8 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
@@ -9,71 +11,69 @@ import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.insets.*
import kotlinx.coroutines.*
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.ModalManager
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@DelicateCoroutinesApi
@Composable
fun ChatView(chatModel: ChatModel, nav: NavController) {
if (chatModel.chatId.value != null && chatModel.chats.count() > 0) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat != null) {
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
delay(1000L)
if (chat.chatItems.count() > 0) {
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
)
}
fun ChatView(chatModel: ChatModel) {
val chat: Chat? = chatModel.chats.firstOrNull { chat -> chat.chatInfo.id == chatModel.chatId.value }
if (chat == null) {
chatModel.chatId.value = null
} else {
BackHandler { chatModel.chatId.value = null }
// TODO a more advanced version would mark as read only if in view
LaunchedEffect(chat.chatItems) {
Log.d(TAG, "ChatView ${chatModel.chatId.value}: LaunchedEffect")
delay(1000L)
if (chat.chatItems.count() > 0) {
chatModel.markChatItemsRead(chat.chatInfo)
withApi {
chatModel.controller.apiChatRead(
chat.chatInfo.chatType,
chat.chatInfo.apiId,
CC.ItemRange(chat.chatStats.minUnreadItemId, chat.chatItems.last().id)
)
}
}
ChatLayout(chat, chatModel.chatItems,
back = { nav.popBackStack() },
info = { nav.navigate(Pages.ChatInfo.route) },
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val newItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
mc = MsgContent.MCText(msg)
)
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
}
)
}
ChatLayout(chat, chatModel.chatItems,
back = { chatModel.chatId.value = null },
info = { ModalManager.shared.showCustomModal { close -> ChatInfoView(chatModel, close) } },
sendMessage = { msg ->
withApi {
// show "in progress"
val cInfo = chat.chatInfo
val newItem = chatModel.controller.apiSendMessage(
type = cInfo.chatType,
id = cInfo.apiId,
mc = MsgContent.MCText(msg)
)
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
}
)
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatLayout(
chat: Chat, chatItems: List<ChatItem>,
@@ -81,19 +81,19 @@ fun ChatLayout(
info: () -> Unit,
sendMessage: (String) -> Unit
) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = { SendMsgView(sendMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxWidth()
.background(MaterialTheme.colors.background)
) {
ChatItemsList(chatItems)
Surface(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background)) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
bottomBar = { SendMsgView(sendMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(chatItems)
}
}
}
}
@@ -101,7 +101,10 @@ fun ChatLayout(
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
Box(Modifier.height(60.dp).padding(horizontal = 8.dp),
Box(
Modifier
.height(60.dp)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart
) {
IconButton(onClick = back) {
@@ -136,12 +139,25 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
val CIListStateSaver = run {
val scrolledKey = "scrolled"
val countKey = "itemCount"
val keyboardKey = "keyboardState"
mapSaver(
save = { mapOf(scrolledKey to it.scrolled, countKey to it.itemCount, keyboardKey to it.keyboardState) },
restore = { CIListState(it[scrolledKey] as Boolean, it[countKey] as Int, it[keyboardKey] as KeyboardState) }
)
}
@Composable
fun ChatItemsList(chatItems: List<ChatItem>) {
val listState = rememberLazyListState()
val keyboardState by getKeyboardState()
val ciListState = rememberSaveable(stateSaver = CIListStateSaver) {
mutableStateOf(CIListState(false, chatItems.count(), keyboardState))
}
val scope = rememberCoroutineScope()
val uriHandler = LocalUriHandler.current
LazyColumn(state = listState) {
@@ -149,16 +165,15 @@ fun ChatItemsList(chatItems: List<ChatItem>) {
ChatItemView(cItem, uriHandler)
}
val len = chatItems.count()
if (len > 1) {
if (keyboardState != ciListState.value.keyboardState || !ciListState.value.scrolled || len != ciListState.value.itemCount) {
scope.launch {
ciListState.value = CIListState(true, len, keyboardState)
listState.animateScrollToItem(len - 1)
}
}
}
}
@ExperimentalTextApi
@ExperimentalAnimatedInsets
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -15,9 +15,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
@@ -33,6 +35,7 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
autoCorrect = true
),
modifier = Modifier.padding(8.dp),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
@@ -1,6 +1,5 @@
package chat.simplex.app.views.chat.item
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
@@ -13,7 +12,6 @@ import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
@ExperimentalTextApi
@Composable
fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
@@ -33,7 +31,6 @@ fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewChatItemView() {
@@ -1,6 +1,7 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
@@ -15,8 +16,9 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.LightGray
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
@@ -24,7 +26,6 @@ import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x1EB1B0B5)
@ExperimentalTextApi
@Composable
fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
@@ -55,11 +56,10 @@ fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMembe
}
}
@ExperimentalTextApi
@Composable
fun MarkdownText (
chatItem: ChatItem,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp),
maxLines: Int = Int.MAX_VALUE,
overflow: TextOverflow = TextOverflow.Clip,
uriHandler: UriHandler? = null,
@@ -110,7 +110,6 @@ fun MarkdownText (
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewSnd() {
@@ -123,7 +122,6 @@ fun PreviewTextItemViewSnd() {
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewRcv() {
@@ -136,7 +134,6 @@ fun PreviewTextItemViewRcv() {
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewLong() {
@@ -1,7 +1,6 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
@@ -18,35 +17,28 @@ import androidx.compose.ui.unit.dp
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.usersettings.simplexTeamUri
val bold = SpanStyle(fontWeight = FontWeight.Bold)
@Composable
fun ChatHelpView(addContact: () -> Unit, doAddContact: Boolean) {
fun ChatHelpView(addContact: (() -> Unit)? = null) {
Column(
Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
val uriHandler = LocalUriHandler.current
Text(
"Thank you for installing SimpleX Chat!",
color = MaterialTheme.colors.onBackground
)
Text("Thank you for installing SimpleX Chat!")
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("You can ")
}
append("You can ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("connect to SimpleX team")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(".")
}
append(".")
},
modifier = Modifier
.clickable(onClick = { uriHandler.openUri(simplexTeamUri) })
modifier = Modifier.clickable(onClick = {
uriHandler.openUri(simplexTeamUri)
})
)
Column(
@@ -56,46 +48,30 @@ fun ChatHelpView(addContact: () -> Unit, doAddContact: Boolean) {
) {
Text(
"To start a new chat",
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.h2
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Tap button",
color = MaterialTheme.colors.onBackground
)
Text("Tap button")
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
modifier = if (doAddContact) Modifier.clickable(onClick = addContact) else Modifier,
tint = MaterialTheme.colors.onBackground,
)
Text(
"above, then:",
color = MaterialTheme.colors.onBackground
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
)
Text("above, then:")
}
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("Add new contact")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(": to create your one-time QR Code for your contact.")
}
withStyle(bold) { append("Add new contact") }
append(": to create your one-time QR Code for your contact.")
}
)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("Scan QR code")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(": to connect to your contact who shows QR code to you.")
}
withStyle(bold) { append("Scan QR code") }
append(": to connect to your contact who shows QR code to you.")
}
)
}
@@ -105,51 +81,22 @@ fun ChatHelpView(addContact: () -> Unit, doAddContact: Boolean) {
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"To connect via link",
color = MaterialTheme.colors.onBackground,
style = MaterialTheme.typography.h2
)
Text(
"If you received SimpleX Chat invitation link you can open it in your browser:",
color = MaterialTheme.colors.onBackground
)
Text("To connect via link", style = MaterialTheme.typography.h2)
Text("If you received SimpleX Chat invitation link you can open it in your browser:")
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
}
withStyle(
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
) {
append("Scan QR code")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(".")
}
append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
withStyle(bold) { append("Scan QR code") }
append(".")
}
)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append("\uD83D\uDCF1 mobile: tap ")
}
withStyle(
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
) {
append("Open in mobile app")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(", then tap ")
}
withStyle(
SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)
) {
append("Connect")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
append(" in the app.")
}
append("\uD83D\uDCF1 mobile: tap ")
withStyle(bold) { append("Open in mobile app") }
append(", then tap ")
withStyle(bold) { append("Connect") }
append(" in the app.")
}
)
}
@@ -165,6 +112,6 @@ fun ChatHelpView(addContact: () -> Unit, doAddContact: Boolean) {
@Composable
fun PreviewChatHelpLayout() {
SimpleXTheme {
ChatHelpView({}, false)
ChatHelpView({})
}
}
@@ -9,50 +9,39 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.datetime.Clock
@ExperimentalTextApi
@Composable
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel, nav: NavController) {
ChatListNavLink(
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
ChatListNavLinkLayout(
chat = chat,
action = {
when (chat.chatInfo) {
is ChatInfo.Direct -> chatNavLink(chat, chatModel, nav)
is ChatInfo.Group -> chatNavLink(chat, chatModel, nav)
is ChatInfo.ContactRequest -> contactRequestNavLink(chat.chatInfo, chatModel, nav)
click = {
if (chat.chatInfo is ChatInfo.ContactRequest) {
contactRequestAlertDialog(chat.chatInfo, chatModel)
} else {
withApi { openChat(chatModel, chat.chatInfo) }
}
}
)
}
@DelicateCoroutinesApi
fun chatNavLink(chatPreview: Chat, chatModel: ChatModel, navController: NavController) {
withApi {
val chatInfo = chatPreview.chatInfo
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
if (chat != null) {
chatModel.chatId.value = chatInfo.id
chatModel.chatItems = chat.chatItems.toMutableStateList()
navController.navigate(Pages.Chat.route)
} else {
// TODO show error? or will apiGetChat show it
}
suspend fun openChat(chatModel: ChatModel, cInfo: ChatInfo) {
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
if (chat != null) {
chatModel.chatItems = chat.chatItems.toMutableStateList()
chatModel.chatId.value = cInfo.id
}
}
@DelicateCoroutinesApi
fun contactRequestNavLink(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel, navController: NavController) {
chatModel.alertManager.showAlertDialog(
fun contactRequestAlertDialog(contactRequest: ChatInfo.ContactRequest, chatModel: ChatModel) {
AlertManager.shared.showAlertDialog(
title = "Accept connection request?",
text = "If you choose to reject sender will NOT be notified",
confirmText = "Accept",
@@ -75,27 +64,12 @@ fun contactRequestNavLink(contactRequest: ChatInfo.ContactRequest, chatModel: Ch
)
}
@ExperimentalTextApi
@Composable
fun ChatListNavLink(chat: Chat, action: () -> Unit) {
ChatListNavLinkLayout(
content = {
when (chat.chatInfo) {
is ChatInfo.Direct -> ChatPreviewView(chat)
is ChatInfo.Group -> ChatPreviewView(chat)
is ChatInfo.ContactRequest -> ContactRequestView(chat)
}
},
action = action
)
}
@Composable
fun ChatListNavLinkLayout(content: (@Composable () -> Unit), action: () -> Unit) {
fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = action)
.clickable(onClick = click)
.height(88.dp)
) {
Row(
@@ -104,18 +78,18 @@ fun ChatListNavLinkLayout(content: (@Composable () -> Unit), action: () -> Unit)
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
verticalAlignment = Alignment.Top,
// TODO?
// verticalAlignment = Alignment.CenterVertically,
// horizontalArrangement = Arrangement.SpaceEvenly
verticalAlignment = Alignment.Top
) {
content.invoke()
if (chat.chatInfo is ChatInfo.ContactRequest) {
ContactRequestView(chat)
} else {
ChatPreviewView(chat)
}
}
}
Divider(Modifier.padding(horizontal = 8.dp))
}
@ExperimentalTextApi
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -125,7 +99,7 @@ fun ChatListNavLinkLayout(content: (@Composable () -> Unit), action: () -> Unit)
@Composable
fun PreviewChatListNavLinkDirect() {
SimpleXTheme {
ChatListNavLink(
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(
@@ -138,12 +112,11 @@ fun PreviewChatListNavLinkDirect() {
),
chatStats = Chat.ChatStats()
),
action = {}
click = {}
)
}
}
@ExperimentalTextApi
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -153,7 +126,7 @@ fun PreviewChatListNavLinkDirect() {
@Composable
fun PreviewChatListNavLinkGroup() {
SimpleXTheme {
ChatListNavLink(
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = listOf(
@@ -166,12 +139,11 @@ fun PreviewChatListNavLinkGroup() {
),
chatStats = Chat.ChatStats()
),
action = {}
click = {}
)
}
}
@ExperimentalTextApi
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -181,13 +153,13 @@ fun PreviewChatListNavLinkGroup() {
@Composable
fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLink(
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.ContactRequest.sampleData,
chatItems = listOf(),
chatStats = Chat.ChatStats()
),
action = {}
click = {}
)
}
}
@@ -8,24 +8,20 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.views.newchat.NewChatSheet
import chat.simplex.app.views.usersettings.SettingsView
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@ExperimentalMaterialApi
class ScaffoldController(val scope: CoroutineScope) {
lateinit var state: BottomSheetScaffoldState
val expanded = mutableStateOf(false)
@@ -41,7 +37,7 @@ class ScaffoldController(val scope: CoroutineScope) {
}
fun toggleSheet() {
if (state.bottomSheetState.isExpanded ?: false) collapse() else expand()
if (state.bottomSheetState.isExpanded) collapse() else expand()
}
fun toggleDrawer() = scope.launch {
@@ -49,7 +45,6 @@ class ScaffoldController(val scope: CoroutineScope) {
}
}
@ExperimentalMaterialApi
@Composable
fun scaffoldController(): ScaffoldController {
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
@@ -64,36 +59,28 @@ fun scaffoldController(): ScaffoldController {
return ctrl
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun ChatListView(chatModel: ChatModel, nav: NavController) {
fun ChatListView(chatModel: ChatModel) {
val scaffoldCtrl = scaffoldController()
BottomSheetScaffold(
scaffoldState = scaffoldCtrl.state,
drawerContent = { SettingsView(chatModel, nav) },
drawerContent = { SettingsView(chatModel) },
sheetPeekHeight = 0.dp,
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl, nav) },
sheetContent = { NewChatSheet(chatModel, scaffoldCtrl) },
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp),
) {
Box {
Column(
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl)
when (chatModel.chatsLoaded.value) {
true -> if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel, nav)
} else {
val user = chatModel.currentUser.value
Help(scaffoldCtrl, displayName = user?.profile?.displayName)
}
else -> ChatList(chatModel, nav)
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
} else {
val user = chatModel.currentUser.value
Help(scaffoldCtrl, displayName = user?.profile?.displayName)
}
}
if (scaffoldCtrl.expanded.value) {
@@ -108,7 +95,6 @@ fun ChatListView(chatModel: ChatModel, nav: NavController) {
}
}
@ExperimentalMaterialApi
@Composable
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
@@ -122,7 +108,7 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView({ scaffoldCtrl.toggleSheet() }, true)
ChatHelpView({ scaffoldCtrl.toggleSheet() })
Row(
Modifier.padding(top = 30.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -142,7 +128,6 @@ fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
}
}
@ExperimentalMaterialApi
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
Row(
@@ -155,7 +140,7 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
) {
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
Icons.Outlined.Settings,
Icons.Outlined.Menu,
"Settings",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
@@ -178,28 +163,14 @@ fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@Composable
fun ChatList(chatModel: ChatModel, navController: NavController) {
fun ChatList(chatModel: ChatModel) {
Divider(Modifier.padding(horizontal = 8.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth()
) {
items(chatModel.chats) { chat ->
ChatListNavLinkView(chat, chatModel, navController)
ChatListNavLinkView(chat, chatModel)
}
}
}
//@Preview
//@Composable
//fun PreviewChatListView() {
// SimpleXTheme {
// ChatListView(
// chats = listOf(
// Chat()
// ),
//
// )
// }
//}
@@ -9,7 +9,6 @@ import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
@@ -23,7 +22,6 @@ import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
@ExperimentalTextApi
@Composable
fun ChatPreviewView(chat: Chat) {
Row {
@@ -78,7 +76,6 @@ fun ChatPreviewView(chat: Chat) {
}
}
@ExperimentalTextApi
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -0,0 +1,82 @@
package chat.simplex.app.views.helpers
import android.util.Log
import androidx.compose.material.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.TAG
class AlertManager {
var alertView = mutableStateOf<(@Composable () -> Unit)?>(null)
var presentAlert = mutableStateOf<Boolean>(false)
fun showAlert(alert: @Composable () -> Unit) {
Log.d(TAG, "AlertManager.showAlert")
alertView.value = alert
presentAlert.value = true
}
fun hideAlert() {
presentAlert.value = false
alertView.value = null
}
fun showAlertDialog(
title: String,
text: String? = null,
confirmText: String = "Ok",
onConfirm: (() -> Unit)? = null,
dismissText: String = "Cancel",
onDismiss: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
},
dismissButton = {
Button(onClick = {
onDismiss?.invoke()
hideAlert()
}) { Text(dismissText) }
}
)
}
}
fun showAlertMsg(
title: String, text: String? = null,
confirmText: String = "Ok", onConfirm: (() -> Unit)? = null
) {
val alertText: (@Composable () -> Unit)? = if (text == null) null else { -> Text(text) }
showAlert {
AlertDialog(
onDismissRequest = this::hideAlert,
title = { Text(title) },
text = alertText,
confirmButton = {
Button(onClick = {
onConfirm?.invoke()
hideAlert()
}) { Text(confirmText) }
}
)
}
}
@Composable
fun showInView() {
if (presentAlert.value) alertView.value?.invoke()
}
companion object {
val shared = AlertManager()
}
}
@@ -1,7 +1,6 @@
package chat.simplex.app.views.helpers
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.MaterialTheme
import androidx.compose.material.icons.Icons
@@ -9,8 +8,6 @@ import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.SupervisedUserCircle
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@@ -0,0 +1,60 @@
package chat.simplex.app.views.newchat
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import chat.simplex.app.TAG
import chat.simplex.app.views.helpers.CloseSheetBar
@Composable
fun ModalView(close: () -> Unit, content: @Composable () -> Unit) {
BackHandler(onBack = close)
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Column {
CloseSheetBar(close)
Box(Modifier.padding(horizontal = 16.dp)) { content() }
}
}
}
class ModalManager {
private val modalViews = arrayListOf<(@Composable (close: () -> Unit) -> Unit)?>()
private val modalCount = mutableStateOf(0)
fun showModal(content: @Composable () -> Unit) {
showCustomModal { close -> ModalView(close, content) }
}
fun showCustomModal(modal: @Composable (close: () -> Unit) -> Unit) {
Log.d(TAG, "ModalManager.showModal")
modalViews.add(modal)
modalCount.value = modalViews.count()
}
fun closeModal() {
if (!modalViews.isEmpty()) {
modalViews.removeAt(modalViews.count() - 1)
}
modalCount.value = modalViews.count()
}
@Composable
fun showInView() {
if (modalCount.value > 0) modalViews.lastOrNull()?.invoke(::closeModal)
}
companion object {
val shared = ModalManager()
}
}
@@ -1,7 +1,40 @@
package chat.simplex.app.views.helpers
import android.graphics.Rect
import android.view.ViewTreeObserver
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalView
import kotlinx.coroutines.*
@DelicateCoroutinesApi
fun withApi(action: suspend CoroutineScope.() -> Unit): Job =
GlobalScope.launch { withContext(Dispatchers.Main, action) }
enum class KeyboardState {
Opened, Closed
}
@Composable
fun getKeyboardState(): State<KeyboardState> {
val keyboardState = remember { mutableStateOf(KeyboardState.Closed) }
val view = LocalView.current
DisposableEffect(view) {
val onGlobalListener = ViewTreeObserver.OnGlobalLayoutListener {
val rect = Rect()
view.getWindowVisibleDisplayFrame(rect)
val screenHeight = view.rootView.height
val keypadHeight = screenHeight - rect.bottom
keyboardState.value = if (keypadHeight > screenHeight * 0.15) {
KeyboardState.Opened
} else {
KeyboardState.Closed
}
}
view.viewTreeObserver.addOnGlobalLayoutListener(onGlobalListener)
onDispose {
view.viewTreeObserver.removeOnGlobalLayoutListener(onGlobalListener)
}
}
return keyboardState
}
@@ -1,7 +1,6 @@
package chat.simplex.app.views.newchat
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -16,68 +15,64 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.shareText
@Composable
fun AddContactView(chatModel: ChatModel, nav: NavController) {
fun AddContactView(chatModel: ChatModel) {
val connReq = chatModel.connReqInvitation
if (connReq != null) {
val cxt = LocalContext.current
AddContactLayout(
connReq = connReq,
close = { nav.popBackStack() },
share = { shareText(cxt, connReq) }
)
}
}
@Composable
fun AddContactLayout(connReq: String, close: () -> Unit, share: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloseSheetBar(close)
Text(
"Add contact",
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Show QR code to your contact\nto scan from the app",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground
)
QRCode(connReq)
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
fun AddContactLayout(connReq: String, share: () -> Unit) {
BoxWithConstraints {
val screenHeight = maxHeight
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceBetween,
) {
Text(
"Add contact",
style = MaterialTheme.typography.h1,
)
Text(
"Show QR code to your contact\nto scan from the app",
style = MaterialTheme.typography.h2.copy(fontSize = if(screenHeight > 600.dp) 26.sp else 20.sp),
textAlign = TextAlign.Center,
)
QRCode(
connReq, Modifier
.weight(1f, fill = false)
.aspectRatio(1f)
.padding(vertical = 3.dp)
)
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can share the invitation link via any other channel.")
}
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp)
)
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption.copy(fontSize=if(screenHeight > 600.dp) 20.sp else 16.sp),
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(bottom = if(screenHeight > 600.dp) 16.dp else 8.dp)
)
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
Spacer(Modifier.height(10.dp))
}
}
}
@@ -92,7 +87,6 @@ fun PreviewAddContactView() {
SimpleXTheme {
AddContactLayout(
connReq = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
close = {},
share = {}
)
}
@@ -2,7 +2,7 @@ package chat.simplex.app.views.newchat
import android.content.res.Configuration
import android.net.Uri
import androidx.compose.foundation.background
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.Composable
@@ -13,47 +13,41 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@Composable
fun ConnectContactView(chatModel: ChatModel, nav: NavController) {
fun ConnectContactView(chatModel: ChatModel, close: () -> Unit) {
BackHandler(onBack = close)
ConnectContactLayout(
qrCodeScanner = {
QRCodeScanner { connReqUri ->
try {
val uri = Uri.parse(connReqUri)
withUriAction(chatModel, uri) { action ->
withUriAction(uri) { action ->
connectViaUri(chatModel, action, uri)
}
} catch (e: RuntimeException) {
chatModel.alertManager.showAlertMsg(
AlertManager.shared.showAlertMsg(
title = "Invalid QR code",
text = "This QR code is not a link!"
)
}
nav.popBackStack()
close()
}
},
close = { nav.popBackStack() }
close = close
)
}
@DelicateCoroutinesApi
fun withUriAction(
chatModel: ChatModel, uri: Uri,
run: suspend (String) -> Unit
) {
fun withUriAction(uri: Uri, run: suspend (String) -> Unit) {
val action = uri.path?.drop(1)
if (action == "contact" || action == "invitation") {
withApi { run(action) }
} else {
chatModel.alertManager.showAlertMsg(
AlertManager.shared.showAlertMsg(
title = "Invalid link!",
text = "This link is not a valid connection link!"
)
@@ -66,7 +60,7 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
val whenConnected =
if (action == "contact") "your connection request is accepted"
else "your contact's device is online"
chatModel.alertManager.showAlertMsg(
AlertManager.shared.showAlertMsg(
title = "Connection request sent!",
text = "You will be connected when $whenConnected, please wait or check later!"
)
@@ -75,50 +69,41 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
@Composable
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
CloseSheetBar(close)
Text(
"Scan QR code",
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"Your chat profile will be sent\nto your contact",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
buildAnnotatedString {
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
ModalView(close) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
"Scan QR code",
style = MaterialTheme.typography.h1,
)
Text(
"Your chat profile will be sent\nto your contact",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 4.dp)
)
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground, fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
withStyle(SpanStyle(color = MaterialTheme.colors.onBackground)) {
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can create the invitation link.")
}
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp)
)
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 4.dp)
)
}
}
}
@@ -14,22 +14,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chatlist.ScaffoldController
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberPermissionState
import kotlinx.coroutines.DelicateCoroutinesApi
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController, nav: NavController) {
fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController) {
val cameraPermissionState = rememberPermissionState(permission = Manifest.permission.CAMERA)
NewChatSheetLayout(
addContact = {
@@ -39,41 +32,48 @@ fun NewChatSheet(chatModel: ChatModel, newChatCtrl: ScaffoldController, nav: Nav
// hide spinner
if (chatModel.connReqInvitation != null) {
newChatCtrl.collapse()
nav.navigate(Pages.AddContact.route)
ModalManager.shared.showModal { AddContactView(chatModel) }
}
}
},
scanCode = {
newChatCtrl.collapse()
nav.navigate(Pages.Connect.route)
ModalManager.shared.showCustomModal { close -> ConnectContactView(chatModel, close) }
cameraPermissionState.launchPermissionRequest()
},
close = {
newChatCtrl.collapse()
}
)
}
@Composable
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit, close: () -> Unit) {
Row(Modifier
fun NewChatSheetLayout(addContact: () -> Unit, scanCode: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 48.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Box(Modifier.weight(1F).fillMaxWidth()) {
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
"Add contact", "(create QR code\nor link)",
Icons.Outlined.PersonAdd, click = addContact
)
}
Box(Modifier.weight(1F).fillMaxWidth()) {
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
"Scan QR code", "(in person or in video call)",
Icons.Outlined.QrCode, click = scanCode
)
}
Box(Modifier.weight(1F).fillMaxWidth()) {
Box(
Modifier
.weight(1F)
.fillMaxWidth()) {
ActionButton(
"Create Group", "(coming soon!)",
Icons.Outlined.GroupAdd, disabled = true
@@ -116,8 +116,7 @@ fun PreviewNewChatSheet() {
SimpleXTheme {
NewChatSheetLayout(
addContact = {},
scanCode = {},
close = {},
scanCode = {}
)
}
}
@@ -4,6 +4,7 @@ import android.graphics.Bitmap
import android.graphics.Color
import androidx.compose.foundation.Image
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.tooling.preview.Preview
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -12,10 +13,11 @@ import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
@Composable
fun QRCode(connReq: String) {
fun QRCode(connReq: String, modifier: Modifier = Modifier) {
Image(
bitmap = qrCodeBitmap(connReq, 1024).asImageBitmap(),
contentDescription = "QR Code"
contentDescription = "QR Code",
modifier = modifier
)
}
@@ -10,6 +10,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import chat.simplex.app.TAG
import com.google.common.util.concurrent.ListenableFuture
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
@@ -62,7 +63,7 @@ fun QRCodeScanner(onBarcode: (String) -> Unit) {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview, imageAnalysis)
} catch (e: Exception) {
Log.d("SIMPLEX", "CameraPreview: ${e.localizedMessage}")
Log.d(TAG, "CameraPreview: ${e.localizedMessage}")
}
}, ContextCompat.getMainExecutor(context))
}
@@ -90,11 +91,11 @@ class BarCodeAnalyser(
if (barcodes.isNotEmpty()) {
onBarcodeDetected(barcodes)
} else {
Log.d("SIMPLEX", "BarcodeAnalyser: No barcode Scanned")
Log.d(TAG, "BarcodeAnalyser: No barcode Scanned")
}
}
.addOnFailureListener { exception ->
Log.d("SIMPLEX", "BarcodeAnalyser: Something went wrong $exception")
Log.e(TAG, "BarcodeAnalyser: Something went wrong $exception")
}
.addOnCompleteListener {
image.close()
@@ -1,8 +1,8 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
@@ -10,40 +10,27 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ChatHelpView
import chat.simplex.app.views.helpers.CloseSheetBar
@Composable
fun HelpView(chatModel: ChatModel, nav: NavController) {
fun HelpView(chatModel: ChatModel) {
val user = chatModel.currentUser.value
if (user != null) {
HelpLayout(
displayName = user.profile.displayName,
back = nav::popBackStack
)
HelpLayout(displayName = user.profile.displayName)
}
}
@Composable
fun HelpLayout(displayName: String, back: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.Start
) {
CloseSheetBar(back)
fun HelpLayout(displayName: String) {
Column(horizontalAlignment = Alignment.Start) {
Text(
"Welcome $displayName!",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView({}, false)
ChatHelpView()
}
}
@@ -56,9 +43,6 @@ fun HelpLayout(displayName: String, back: () -> Unit) {
@Composable
fun PreviewHelpView() {
SimpleXTheme {
HelpLayout(
displayName = "Alice",
back = {}
)
HelpLayout(displayName = "Alice")
}
}
@@ -1,72 +1,53 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
@Composable
fun MarkdownHelpView(nav: NavController) {
MarkdownHelpLayout(nav::popBackStack)
}
@Composable
fun MarkdownHelpLayout(back: () -> Unit) {
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
Column {
CloseSheetBar(back)
Column(Modifier.padding(horizontal = 16.dp)) {
Text(
"How to use markdown",
style = MaterialTheme.typography.h1,
)
Text(
"You can use markdown to format messages:",
Modifier.padding(vertical = 16.dp)
)
MdFormat("*bold*", "bold text", Format.Bold())
MdFormat("_italic_", "italic text", Format.Italic())
MdFormat("~strike~", "strikethrough text", Format.StrikeThrough())
MdFormat("`code`", "a = b + c", Format.Snippet())
Row {
MdSyntax("!1 colored!")
Text(buildAnnotatedString {
withStyle(Format.Colored(FormatColor.red).style) { append("red text") }
append(" (")
appendColor(this, "1", FormatColor.red, ", ")
appendColor(this, "2", FormatColor.green, ", ")
appendColor(this, "3", FormatColor.blue, ", ")
appendColor(this, "4", FormatColor.yellow, ", ")
appendColor(this, "5", FormatColor.cyan, ", ")
appendColor(this, "6", FormatColor.magenta, ")")
})
}
Row {
MdSyntax("#secret")
SelectionContainer {
Text(buildAnnotatedString {
withStyle(Format.Secret().style) { append("secret text") }
})
}
}
fun MarkdownHelpView() {
Column(Modifier.padding(horizontal = 16.dp)) {
Text(
"How to use markdown",
style = MaterialTheme.typography.h1,
)
Text(
"You can use markdown to format messages:",
Modifier.padding(vertical = 16.dp)
)
MdFormat("*bold*", "bold", Format.Bold())
MdFormat("_italic_", "italic", Format.Italic())
MdFormat("~strike~", "strike", Format.StrikeThrough())
MdFormat("`a + b`", "a + b", Format.Snippet())
Row {
MdSyntax("!1 colored!")
Text(buildAnnotatedString {
withStyle(Format.Colored(FormatColor.red).style) { append("colored") }
append(" (")
appendColor(this, "1", FormatColor.red, ", ")
appendColor(this, "2", FormatColor.green, ", ")
appendColor(this, "3", FormatColor.blue, ", ")
appendColor(this, "4", FormatColor.yellow, ", ")
appendColor(this, "5", FormatColor.cyan, ", ")
appendColor(this, "6", FormatColor.magenta, ")")
})
}
Row {
MdSyntax("#secret#")
SelectionContainer {
Text(buildAnnotatedString {
withStyle(Format.Secret().style) { append("secret") }
})
}
}
}
@@ -105,6 +86,6 @@ fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: St
@Composable
fun PreviewMarkdownHelpView() {
SimpleXTheme {
MarkdownHelpLayout(back = {})
MarkdownHelpView()
}
}
@@ -0,0 +1,300 @@
package chat.simplex.app.views.usersettings
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.OpenInNew
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
@Composable
fun SMPServersView(chatModel: ChatModel) {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
var isUserSMPServers by remember { mutableStateOf(userSMPServers.isNotEmpty()) }
var editSMPServers by remember { mutableStateOf(!isUserSMPServers) }
var userSMPServersStr by remember { mutableStateOf(if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else "") }
fun saveSMPServers(smpServers: List<String>) {
withApi {
val r = chatModel.controller.setUserSMPServers(smpServers = smpServers)
if (r) {
chatModel.userSMPServers.value = smpServers
if (smpServers.isEmpty()) {
isUserSMPServers = false
editSMPServers = true
} else {
editSMPServers = false
}
}
}
}
SMPServersLayout(
isUserSMPServers = isUserSMPServers,
editSMPServers = editSMPServers,
userSMPServersStr = userSMPServersStr,
isUserSMPServersOnOff = { switch ->
if (switch) {
isUserSMPServers = true
} else {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
if (userSMPServers.isNotEmpty()) {
AlertManager.shared.showAlertMsg(
title = "Use SimpleX Chat servers?",
text = "Saved SMP servers will be removed",
confirmText = "Confirm",
onConfirm = {
saveSMPServers(listOf())
isUserSMPServers = false
userSMPServersStr = ""
}
)
} else {
isUserSMPServers = false
userSMPServersStr = ""
}
}
}
},
editUserSMPServersStr = { userSMPServersStr = it },
cancelEdit = {
val userSMPServers = chatModel.userSMPServers.value
if (userSMPServers != null) {
isUserSMPServers = userSMPServers.isNotEmpty()
editSMPServers = !isUserSMPServers
userSMPServersStr = if (isUserSMPServers) userSMPServers.joinToString(separator = "\n") else ""
}
},
saveSMPServers = { saveSMPServers(it) },
editOn = { editSMPServers = true },
)
}
}
@Composable
fun SMPServersLayout(
isUserSMPServers: Boolean,
editSMPServers: Boolean,
userSMPServersStr: String,
isUserSMPServersOnOff: (Boolean) -> Unit,
editUserSMPServersStr: (String) -> Unit,
cancelEdit: () -> Unit,
saveSMPServers: (List<String>) -> Unit,
editOn: () -> Unit,
) {
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"Your SMP servers",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text("Configure SMP servers", Modifier.padding(end = 24.dp))
Switch(
checked = isUserSMPServers,
onCheckedChange = isUserSMPServersOnOff,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colors.primary,
uncheckedThumbColor = HighOrLowlight
),
)
}
if (!isUserSMPServers) {
Text("Using SimpleX Chat servers.")
} else {
Text("Enter one SMP server per line:")
if (editSMPServers) {
BasicTextField(
value = userSMPServersStr,
onValueChange = editUserSMPServersStr,
textStyle = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 14.sp,
color = MaterialTheme.colors.onBackground
),
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
modifier = Modifier.height(160.dp),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
Row(
Modifier.background(MaterialTheme.colors.background),
verticalAlignment = Alignment.Top
) {
Box(
Modifier
.weight(1f)
.padding(vertical = 5.dp, horizontal = 7.dp)
) {
innerTextField()
}
}
}
}
)
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Row {
Text(
"Cancel",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = cancelEdit)
)
Spacer(Modifier.padding(horizontal = 8.dp))
Text(
"Save",
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable(onClick = {
val servers = userSMPServersStr.split("\n")
saveSMPServers(servers)
})
)
}
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
} else {
Surface(
modifier = Modifier
.height(160.dp)
.fillMaxWidth(),
shape = RoundedCornerShape(10.dp),
border = BorderStroke(1.dp, MaterialTheme.colors.secondary)
) {
SelectionContainer(
Modifier.verticalScroll(rememberScrollState())
) {
Text(
userSMPServersStr,
Modifier
.padding(vertical = 5.dp, horizontal = 7.dp),
style = TextStyle(fontFamily = FontFamily.Monospace, fontSize = 14.sp),
)
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(horizontalAlignment = Alignment.Start) {
Text(
"Edit",
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(onClick = editOn)
)
}
Column(horizontalAlignment = Alignment.End) {
howToButton()
}
}
}
}
}
}
@Composable
fun howToButton() {
val uriHandler = LocalUriHandler.current
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { uriHandler.openUri("https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent") }
) {
Text("How to", color = MaterialTheme.colors.primary)
Icon(
Icons.Outlined.OpenInNew, "How to", tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(horizontal = 5.dp)
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutDefaultServers() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = false,
editSMPServers = true,
userSMPServersStr = "",
isUserSMPServersOnOff = {},
editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOn() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = true,
userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
isUserSMPServersOnOff = {},
editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@Preview(showBackground = true)
@Composable
fun PreviewSMPServersLayoutUserServersEditOff() {
SimpleXTheme {
SMPServersLayout(
isUserSMPServers = true,
editSMPServers = false,
userSMPServersStr = "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im\nsmp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
isUserSMPServersOnOff = {},
editUserSMPServersStr = {},
cancelEdit = {},
saveSMPServers = {},
editOn = {},
)
}
}
@@ -17,20 +17,21 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.Pages
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.newchat.ModalManager
@Composable
fun SettingsView(chatModel: ChatModel, nav: NavController) {
fun SettingsView(chatModel: ChatModel) {
val user = chatModel.currentUser.value
if (user != null) {
SettingsLayout(
profile = user.profile,
navigate = nav::navigate
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
)
}
}
@@ -41,7 +42,8 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: Profile,
navigate: (String) -> Unit
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
@@ -59,10 +61,11 @@ fun SettingsLayout(
Text(
"Your Settings",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 8.dp)
)
Spacer(Modifier.height(30.dp))
SettingsSectionView({ navigate(Pages.UserProfile.route) }, 60.dp) {
SettingsSectionView(showModal { UserProfileView(it) }, 60.dp) {
Icon(
Icons.Outlined.AccountCircle,
contentDescription = "Avatar Placeholder",
@@ -78,7 +81,7 @@ fun SettingsLayout(
}
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ navigate(Pages.UserAddress.route) }) {
SettingsSectionView(showModal { UserAddressView(it) }) {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Address",
@@ -88,7 +91,7 @@ fun SettingsLayout(
}
Spacer(Modifier.height(24.dp))
SettingsSectionView({ navigate(Pages.Help.route) }) {
SettingsSectionView(showModal { HelpView(it) }) {
Icon(
Icons.Outlined.HelpOutline,
contentDescription = "Chat help",
@@ -96,7 +99,8 @@ fun SettingsLayout(
Spacer(Modifier.padding(horizontal = 4.dp))
Text("How to use SimpleX Chat")
}
SettingsSectionView({ navigate(Pages.Markdown.route) }) {
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { MarkdownHelpView() }) {
Icon(
Icons.Outlined.TextFormat,
contentDescription = "Markdown help",
@@ -130,7 +134,16 @@ fun SettingsLayout(
}
Spacer(Modifier.height(24.dp))
SettingsSectionView({ navigate(Pages.Terminal.route) }) {
SettingsSectionView(showModal { SMPServersView(it) }) {
Icon(
Icons.Outlined.Dns,
contentDescription = "SMP servers",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text("SMP servers")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showTerminal) {
Icon(
painter = painterResource(id = R.drawable.ic_outline_terminal),
contentDescription = "Chat console",
@@ -159,12 +172,12 @@ fun SettingsLayout(
}
@Composable
fun SettingsSectionView(func: () -> Unit, height: Dp = 48.dp, content: (@Composable () -> Unit)) {
fun SettingsSectionView(click: () -> Unit, height: Dp = 48.dp, content: (@Composable () -> Unit)) {
Row(
Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.clickable(onClick = func)
.clickable(onClick = click)
.height(height),
verticalAlignment = Alignment.CenterVertically
) {
@@ -183,7 +196,8 @@ fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
navigate = {}
showModal = {{}},
showTerminal = {}
)
}
}
@@ -1,7 +1,6 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -14,7 +13,6 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleButton
import chat.simplex.app.ui.theme.SimpleXTheme
@@ -22,11 +20,10 @@ import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun UserAddressView(chatModel: ChatModel, nav: NavController) {
fun UserAddressView(chatModel: ChatModel) {
val cxt = LocalContext.current
UserAddressLayout(
userAddress = chatModel.userAddress.value,
back = { nav.popBackStack() },
createAddress = {
withApi {
chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
@@ -34,7 +31,7 @@ fun UserAddressView(chatModel: ChatModel, nav: NavController) {
},
share = { userAddress: String -> shareText(cxt, userAddress) },
deleteAddress = {
chatModel.alertManager.showAlertMsg(
AlertManager.shared.showAlertMsg(
title = "Delete address?",
text = "All your contacts will remain connected",
confirmText = "Delete",
@@ -52,45 +49,37 @@ fun UserAddressView(chatModel: ChatModel, nav: NavController) {
@Composable
fun UserAddressLayout(
userAddress: String?,
back: () -> Unit,
createAddress: () -> Unit,
share: (String) -> Unit,
deleteAddress: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top
) {
CloseSheetBar(back)
Text(
"Your chat address",
Modifier.padding(bottom = 24.dp),
Modifier.padding(bottom = 16.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Text(
"You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
"and if you later delete it - you won't lose your contacts.",
Modifier.padding(bottom = 24.dp),
color = MaterialTheme.colors.onBackground
Modifier.padding(bottom = 12.dp),
)
Column(
Modifier
.fillMaxWidth()
.padding(top = 12.dp),
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(24.dp)
verticalArrangement = Arrangement.SpaceEvenly
) {
if (userAddress == null) {
SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
} else {
QRCode(userAddress)
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp)
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 10.dp)
) {
SimpleButton(
"Share link",
@@ -119,7 +108,6 @@ fun PreviewUserAddressLayoutNoAddress() {
SimpleXTheme {
UserAddressLayout(
userAddress = null,
back = {},
createAddress = {},
share = { _ -> },
deleteAddress = {},
@@ -138,7 +126,6 @@ fun PreviewUserAddressLayoutAddressCreated() {
SimpleXTheme {
UserAddressLayout(
userAddress = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D",
back = {},
createAddress = {},
share = { _ -> },
deleteAddress = {},
@@ -1,7 +1,6 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicTextField
@@ -15,15 +14,13 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.CloseSheetBar
import chat.simplex.app.views.helpers.withApi
@Composable
fun UserProfileView(chatModel: ChatModel, nav: NavController) {
fun UserProfileView(chatModel: ChatModel) {
val user = chatModel.currentUser.value
if (user != null) {
var editProfile by remember { mutableStateOf(false) }
@@ -31,7 +28,6 @@ fun UserProfileView(chatModel: ChatModel, nav: NavController) {
UserProfileLayout(
editProfile = editProfile,
profile = profile,
back = { nav.popBackStack() },
editProfileOff = { editProfile = false },
editProfileOn = { editProfile = true },
saveProfile = { displayName: String, fullName: String ->
@@ -54,19 +50,11 @@ fun UserProfileView(chatModel: ChatModel, nav: NavController) {
fun UserProfileLayout(
editProfile: Boolean,
profile: Profile,
back: () -> Unit,
editProfileOff: () -> Unit,
editProfileOn: () -> Unit,
saveProfile: (String, String) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.Start
) {
CloseSheetBar(back)
Column(horizontalAlignment = Alignment.Start) {
Text(
"Your chat profile",
Modifier.padding(bottom = 24.dp),
@@ -185,7 +173,6 @@ fun PreviewUserProfileLayoutEditOff() {
UserProfileLayout(
profile = Profile.sampleData,
editProfile = false,
back = {},
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
@@ -205,7 +192,6 @@ fun PreviewUserProfileLayoutEditOn() {
UserProfileLayout(
profile = Profile.sampleData,
editProfile = true,
back = {},
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+3 -3
View File
@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.1.1'
classpath 'com.android.tools.build:gradle:7.1.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.6.10"
classpath "org.jetbrains.kotlin:kotlin-serialization:1.3.2"
@@ -16,8 +16,8 @@ buildscript {
}
}// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '7.1.1' apply false
id 'com.android.library' version '7.1.1' apply false
id 'com.android.application' version '7.1.2' apply false
id 'com.android.library' version '7.1.2' apply false
id 'org.jetbrains.kotlin.android' version '1.6.10' apply false
id 'org.jetbrains.kotlin.plugin.serialization' version '1.6.10'
}
+18 -19
View File
@@ -13,32 +13,31 @@ struct ContentView: View {
@State private var showNotificationAlert = false
var body: some View {
ZStack {
if let user = chatModel.currentUser {
ChatListView(user: user)
.onAppear {
do {
try apiStartChat()
chatModel.chats = try apiGetChats()
} catch {
fatalError("Failed to start or load chats: \(error)")
}
ChatReceiver.shared.start()
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
if let user = chatModel.currentUser {
ChatListView(user: user)
.onAppear {
do {
try apiStartChat()
chatModel.userSMPServers = try getUserSMPServers()
chatModel.chats = try apiGetChats()
} catch {
fatalError("Failed to start or load chats: \(error)")
}
} else {
WelcomeView()
}
ChatReceiver.shared.start()
NtfManager.shared.requestAuthorization(onDeny: {
alertManager.showAlert(notificationAlert())
})
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
} else {
WelcomeView()
}
.alert(isPresented: $alertManager.presentAlert) { alertManager.alertView! }
}
func notificationAlert() -> Alert {
Alert(
title: Text("Notification are disabled!"),
message: Text("Please open settings to enable"),
message: Text("The app can notify you when you receive messages or contact requests - please open settings to enable."),
primaryButton: .default(Text("Open Settings")) {
DispatchQueue.main.async {
UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!, options: [:], completionHandler: nil)
+14 -10
View File
@@ -21,7 +21,11 @@ final class ChatModel: ObservableObject {
// items in the terminal view
@Published var terminalItems: [TerminalItem] = []
@Published var userAddress: String?
@Published var userSMPServers: [String]?
@Published var appOpenUrl: URL?
var messageDelivery: Dictionary<Int64, () -> Void> = [:]
static let shared = ChatModel()
func hasChat(_ id: String) -> Bool {
@@ -526,7 +530,7 @@ struct ChatItem: Identifiable, Decodable {
var id: Int64 { get { meta.itemId } }
var timestampText: String { get { meta.timestampText } }
var timestampText: Text { get { meta.timestampText } }
func isRcvNew() -> Bool {
if case .rcvNew = meta.itemStatus { return true }
@@ -567,7 +571,7 @@ struct CIMeta: Decodable {
var itemStatus: CIStatus
var createdAt: Date
var timestampText: String { get { SimpleX.timestampText(itemTs) } }
var timestampText: Text { get { SimpleX.timestampText(itemTs) } }
static func getSample(_ id: Int64, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> CIMeta {
CIMeta(
@@ -580,13 +584,14 @@ struct CIMeta: Decodable {
}
}
let msgTimeFormat = Date.FormatStyle.dateTime.hour().minute()
let msgDateFormat = Date.FormatStyle.dateTime.day(.twoDigits).month(.twoDigits)
func timestampText(_ date: Date) -> String {
func timestampText(_ date: Date) -> Text {
let now = Calendar.current.dateComponents([.day, .hour], from: .now)
let dc = Calendar.current.dateComponents([.day, .hour], from: date)
return now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12)
? date.formatted(date: .omitted, time: .shortened)
: String(date.formatted(date: .numeric, time: .omitted).prefix(5))
let recent = now.day == dc.day || ((now.day ?? 0) - (dc.day ?? 0) == 1 && (dc.hour ?? 0) >= 18 && (now.hour ?? 0) < 12)
return Text(date, format: recent ? msgTimeFormat : msgDateFormat)
}
enum CIStatus: Decodable {
@@ -622,15 +627,14 @@ struct RcvFileTransfer: Decodable {
enum MsgContent {
case text(String)
// TODO include original JSON, possibly using https://github.com/zoul/generic-json-swift
case unknown(type: String, text: String)
case invalid(error: String)
var text: String {
get {
switch self {
case let .text(text): return text
case let .unknown(_, text): return text
case .invalid: return "invalid"
}
}
}
@@ -652,8 +656,8 @@ enum MsgContent {
extension MsgContent: Decodable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
do {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: CodingKeys.type)
switch type {
case "text":
@@ -664,7 +668,7 @@ extension MsgContent: Decodable {
self = .unknown(type: type, text: text ?? "unknown message format")
}
} catch {
self = .invalid(error: String(describing: error))
self = .unknown(type: "unknown", text: "invalid message format")
}
}
}
+1
View File
@@ -178,6 +178,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
if let s = body { content.body = s }
content.targetContentIdentifier = targetContentIdentifier
content.userInfo = userInfo
// TODO move logic of adding sound here, so it applies to background notifications too
content.sound = .default
// content.interruptionLevel = .active
// content.relevanceScore = 0.5 // 0-1
+114 -11
View File
@@ -22,6 +22,8 @@ enum ChatCommand {
case apiGetChats
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
case getUserSMPServers
case setUserSMPServers(smpServers: [String])
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
@@ -43,6 +45,8 @@ enum ChatCommand {
case .apiGetChats: return "/_get chats"
case let .apiGetChat(type, id): return "/_get chat \(ref(type, id)) count=100"
case let .apiSendMessage(type, id, mc): return "/_send \(ref(type, id)) \(mc.cmdString)"
case .getUserSMPServers: return "/smp_servers"
case let .setUserSMPServers(smpServers): return "/smp_servers \(smpServersStr(smpServers: smpServers))"
case .addContact: return "/connect"
case let .connect(connReq): return "/connect \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))"
@@ -67,6 +71,8 @@ enum ChatCommand {
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
case .getUserSMPServers: return "getUserSMPServers"
case .setUserSMPServers: return "setUserSMPServers"
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
@@ -85,6 +91,10 @@ enum ChatCommand {
func ref(_ type: ChatType, _ id: Int64) -> String {
"\(type.rawValue)\(id)"
}
func smpServersStr(smpServers: [String]) -> String {
smpServers.isEmpty ? "default" : smpServers.joined(separator: ",")
}
}
struct APIResponse: Decodable {
@@ -95,8 +105,10 @@ enum ChatResponse: Decodable, Error {
case response(type: String, json: String)
case activeUser(user: User)
case chatStarted
case chatRunning
case apiChats(chats: [ChatData])
case apiChat(chat: ChatData)
case userSMPServers(smpServers: [String])
case invitation(connReqInvitation: String)
case sentConfirmation
case sentInvitation
@@ -131,8 +143,10 @@ enum ChatResponse: Decodable, Error {
case let .response(type, _): return "* \(type)"
case .activeUser: return "activeUser"
case .chatStarted: return "chatStarted"
case .chatRunning: return "chatRunning"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .userSMPServers: return "userSMPServers"
case .invitation: return "invitation"
case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation"
@@ -170,8 +184,10 @@ enum ChatResponse: Decodable, Error {
case let .response(_, json): return json
case let .activeUser(user): return String(describing: user)
case .chatStarted: return noDetails
case .chatRunning: return noDetails
case let .apiChats(chats): return String(describing: chats)
case let .apiChat(chat): return String(describing: chat)
case let .userSMPServers(smpServers): return String(describing: smpServers)
case let .invitation(connReqInvitation): return connReqInvitation
case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails
@@ -238,10 +254,53 @@ enum TerminalItem: Identifiable {
}
}
func chatSendCmdSync(_ cmd: ChatCommand) -> ChatResponse {
private func _sendCmd(_ cmd: ChatCommand) -> ChatResponse {
var c = cmd.cmdString.cString(using: .utf8)!
return chatResponse(chat_send_cmd(getChatCtrl(), &c))
}
private func beginBGTask(_ handler: (() -> Void)? = nil) -> (() -> Void) {
var id: UIBackgroundTaskIdentifier!
var running = true
let endTask = {
// logger.debug("beginBGTask: endTask \(id.rawValue)")
if running {
running = false
if let h = handler {
// logger.debug("beginBGTask: user handler")
h()
}
if id != .invalid {
UIApplication.shared.endBackgroundTask(id)
id = .invalid
}
}
}
id = UIApplication.shared.beginBackgroundTask(expirationHandler: endTask)
// logger.debug("beginBGTask: \(id.rawValue)")
return endTask
}
let msgDelay: Double = 7.5
let maxTaskDuration: Double = 15
private func withBGTask(bgDelay: Double? = nil, f: @escaping () -> ChatResponse) -> ChatResponse {
let endTask = beginBGTask()
DispatchQueue.global().asyncAfter(deadline: .now() + maxTaskDuration, execute: endTask)
let r = f()
if let d = bgDelay {
DispatchQueue.global().asyncAfter(deadline: .now() + d, execute: endTask)
} else {
endTask()
}
return r
}
func chatSendCmdSync(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) -> ChatResponse {
logger.debug("chatSendCmd \(cmd.cmdType)")
let resp = chatResponse(chat_send_cmd(getChatCtrl(), &c))
let resp = bgTask
? withBGTask(bgDelay: bgDelay) { _sendCmd(cmd) }
: _sendCmd(cmd)
logger.debug("chatSendCmd \(cmd.cmdType): \(resp.responseType)")
if case let .response(_, json) = resp {
logger.debug("chatSendCmd \(cmd.cmdType) response: \(json)")
@@ -253,16 +312,19 @@ func chatSendCmdSync(_ cmd: ChatCommand) -> ChatResponse {
return resp
}
func chatSendCmd(_ cmd: ChatCommand) async -> ChatResponse {
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) async -> ChatResponse {
await withCheckedContinuation { cont in
cont.resume(returning: chatSendCmdSync(cmd))
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay))
}
}
func chatRecvMsg() async -> ChatResponse {
await withCheckedContinuation { cont in
let resp = chatResponse(chat_recv_msg(getChatCtrl())!)
cont.resume(returning: resp)
_ = withBGTask(bgDelay: msgDelay) {
let resp = chatResponse(chat_recv_msg(getChatCtrl())!)
cont.resume(returning: resp)
return resp
}
}
}
@@ -301,13 +363,42 @@ func apiGetChat(type: ChatType, id: Int64) async throws -> Chat {
}
func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) async throws -> ChatItem {
let r = await chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
if case let .newChatItem(aChatItem) = r { return aChatItem.chatItem }
let chatModel = ChatModel.shared
let cmd = ChatCommand.apiSendMessage(type: type, id: id, msg: msg)
let r: ChatResponse
if type == .direct {
var cItem: ChatItem!
let endTask = beginBGTask({ if cItem != nil { chatModel.messageDelivery.removeValue(forKey: cItem.id) } })
r = await chatSendCmd(cmd, bgTask: false)
if case let .newChatItem(aChatItem) = r {
cItem = aChatItem.chatItem
chatModel.messageDelivery[cItem.id] = endTask
return cItem
}
endTask()
} else {
r = await chatSendCmd(cmd, bgDelay: msgDelay)
if case let .newChatItem(aChatItem) = r {
return aChatItem.chatItem
}
}
throw r
}
func apiAddContact() async throws -> String {
let r = await chatSendCmd(.addContact)
func getUserSMPServers() throws -> [String] {
let r = chatSendCmdSync(.getUserSMPServers)
if case let .userSMPServers(smpServers) = r { return smpServers }
throw r
}
func setUserSMPServers(smpServers: [String]) async throws {
let r = await chatSendCmd(.setUserSMPServers(smpServers: smpServers))
if case .cmdOk = r { return }
throw r
}
func apiAddContact() throws -> String {
let r = chatSendCmdSync(.addContact, bgTask: false)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
throw r
}
@@ -322,7 +413,7 @@ func apiConnect(connReq: String) async throws {
}
func apiDeleteChat(type: ChatType, id: Int64) async throws {
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id))
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false)
if case .contactDeleted = r { return }
throw r
}
@@ -447,6 +538,8 @@ class ChatReceiver {
self._lastMsgTime = .now
processReceivedMsg(msg)
if self.receiveMessages {
do { try await Task.sleep(nanoseconds: 7_500_000) }
catch { logger.error("receiveMsgLoop: Task.sleep error: \(error.localizedDescription)") }
await receiveMsgLoop()
}
}
@@ -505,6 +598,13 @@ func processReceivedMsg(_ res: ChatResponse) {
let cItem = aChatItem.chatItem
if chatModel.upsertChatItem(cInfo, cItem) {
NtfManager.shared.notifyMessageReceived(cInfo, cItem)
} else if let endTask = chatModel.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus {
case .sndSent: endTask()
case .sndErrorAuth: endTask()
case .sndError: endTask()
default: break
}
}
default:
logger.debug("unsupported event: \(res.responseType)")
@@ -574,7 +674,10 @@ private func getChatCtrl() -> chat_ctrl {
if let controller = chatController { return controller }
let dataDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.path + "/mobile_v1"
var cstr = dataDir.cString(using: .utf8)!
logger.debug("getChatCtrl: chat_init")
ChatModel.shared.terminalItems.append(.cmd(.now, .string("chat_init")))
chatController = chat_init(&cstr)
ChatModel.shared.terminalItems.append(.resp(.now, .response(type: "chat_controller", json: "chat_controller: no details")))
return chatController!
}
@@ -25,7 +25,7 @@ struct CIMetaView: View {
default: EmptyView()
}
Text(chatItem.timestampText)
chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
}
@@ -55,8 +55,8 @@ struct TextItemView: View {
}
}
private func reserveSpaceForMeta(_ meta: String) -> Text {
Text(" \(meta)")
private func reserveSpaceForMeta(_ meta: Text) -> Text {
(Text(" ") + meta)
.font(.caption)
.foregroundColor(.clear)
}
@@ -84,7 +84,7 @@ struct ChatListView: View {
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
return Alert(
title: Text("Connect via \(action) link?"),
message: Text("Your profile will be sent to the contact that you received this link from: \(link)"),
message: Text("Your profile will be sent to the contact that you received this link from"),
primaryButton: .default(Text("Connect")) {
DispatchQueue.main.async {
Task {
@@ -39,7 +39,7 @@ struct ChatPreviewView: View {
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
Text(cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt))
(cItem?.timestampText ?? timestampText(chat.chatInfo.createdAt))
.font(.subheadline)
.frame(minWidth: 60, alignment: .trailing)
.foregroundColor(.secondary)
@@ -28,7 +28,7 @@ struct ContactRequestView: View {
.padding(.top, 4)
.frame(maxHeight: .infinity, alignment: .topLeading)
Spacer()
Text(timestampText(contactRequest.createdAt))
timestampText(contactRequest.createdAt)
.font(.subheadline)
.padding(.trailing, 8)
.padding(.top, 4)
@@ -22,7 +22,9 @@ struct AddContactView: View {
.multilineTextAlignment(.center)
QRCode(uri: connReqInvitation)
.padding()
Text("If you can't show QR code, you can share the invitation link via any channel")
(Text("If you cannot meet in person, you can ") +
Text("scan QR code in the video call").bold() +
Text(", or you can share the invitation link via any other channel."))
.font(.subheadline)
.multilineTextAlignment(.center)
.padding(.horizontal)
@@ -35,18 +35,20 @@ struct NewChatButton: View {
}
func addContactAction() {
Task {
do {
connReqInvitation = try await apiAddContact()
addContact = true
} catch {
DispatchQueue.global().async {
connectionErrorAlert(error)
}
logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
do {
connReqInvitation = try apiAddContact()
addContact = true
} catch {
DispatchQueue.global().async {
connectionErrorAlert(error)
}
logger.error("NewChatButton.addContactAction apiAddContact error: \(error.localizedDescription)")
}
}
func addContactSheet() -> some View {
AddContactView(connReqInvitation: connReqInvitation)
}
func connectContactSheet() -> some View {
ConnectContactView(completed: { err in
@@ -13,13 +13,13 @@ struct MarkdownHelp: View {
VStack(alignment: .leading, spacing: 8) {
Text("You can use markdown to format messages:")
.padding(.bottom)
mdFormat("*bold*", Text("bold text").bold())
mdFormat("_italic_", Text("italic text").italic())
mdFormat("~strike~", Text("strikethrough text").strikethrough())
mdFormat("`code`", Text("`a = b + c`").font(.body.monospaced()))
mdFormat("!1 colored!", Text("red text").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")"))
mdFormat("*bold*", Text("bold").bold())
mdFormat("_italic_", Text("italic").italic())
mdFormat("~strike~", Text("strike").strikethrough())
mdFormat("`a + b`", Text("`a + b`").font(.body.monospaced()))
mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")"))
(
mdFormat("#secret#", Text("secret text")
mdFormat("#secret#", Text("secret")
.foregroundColor(.clear)
.underline(color: .primary) + Text(" (can be copied)"))
)
@@ -0,0 +1,180 @@
//
// SMPServers.swift
// SimpleX
//
// Created by Efim Poberezkin on 02.03.2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let serversFont = Font.custom("Menlo", size: 14)
private let howToUrl = URL(string: "https://github.com/simplex-chat/simplexmq#using-smp-server-and-smp-agent")!
struct SMPServers: View {
@EnvironmentObject var chatModel: ChatModel
@State var isUserSMPServers = false
@State var isUserSMPServersToggle = false
@State var editSMPServers = true
@State var userSMPServersStr = ""
@State var showBadServersAlert = false
@State var showResetServersAlert = false
@FocusState private var keyboardVisible: Bool
var body: some View {
return VStack(alignment: .leading) {
Toggle("Configure SMP servers", isOn: $isUserSMPServersToggle)
.onChange(of: isUserSMPServersToggle) { _ in
if (isUserSMPServersToggle) {
isUserSMPServers = true
} else {
let servers = chatModel.userSMPServers ?? []
if (!servers.isEmpty) {
showResetServersAlert = true
} else {
isUserSMPServers = false
userSMPServersStr = ""
}
}
}
.padding(.bottom)
.alert(isPresented: $showResetServersAlert) {
Alert(
title: Text("Use SimpleX Chat servers?"),
message: Text("Saved SMP servers will be removed"),
primaryButton: .destructive(Text("Confirm")) {
saveSMPServers(smpServers: [])
isUserSMPServers = false
userSMPServersStr = ""
}, secondaryButton: .cancel() {
withAnimation() {
isUserSMPServersToggle = true
}
}
)
}
if !isUserSMPServers {
Text("Using SimpleX Chat servers.")
.frame(maxWidth: .infinity, alignment: .leading)
} else {
VStack(alignment: .leading) {
Text("Enter one SMP server per line:")
if editSMPServers {
TextEditor(text: $userSMPServersStr)
.focused($keyboardVisible)
.font(serversFont)
.disableAutocorrection(true)
.textInputAutocapitalization(.never)
.padding(.horizontal, 5)
.padding(.top, 2)
.frame(height: 112)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
HStack(spacing: 20) {
Button("Cancel") {
initialize()
}
Button("Save") {
saveUserSMPServers()
}
.alert(isPresented: $showBadServersAlert) {
Alert(title: Text("Error saving SMP servers"), message: Text("Make sure SMP server addresses are in correct format, line separated and are not duplicated"))
}
Spacer()
howToButton()
}
} else {
ScrollView {
Text(userSMPServersStr)
.font(serversFont)
.padding(10)
.frame(minHeight: 0, alignment: .topLeading)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(height: 160)
.overlay(
RoundedRectangle(cornerRadius: 10)
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
)
HStack {
Button("Edit") {
editSMPServers = true
}
Spacer()
howToButton()
}
}
}
.frame(maxWidth: .infinity)
}
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
.onAppear { initialize() }
}
func initialize() {
let userSMPServers = chatModel.userSMPServers ?? []
isUserSMPServers = !userSMPServers.isEmpty
isUserSMPServersToggle = isUserSMPServers
editSMPServers = !isUserSMPServers
userSMPServersStr = isUserSMPServers ? userSMPServers.joined(separator: "\n") : ""
}
func saveUserSMPServers() {
let userSMPServers = userSMPServersStr.components(separatedBy: "\n")
saveSMPServers(smpServers: userSMPServers)
}
func saveSMPServers(smpServers: [String]) {
Task {
do {
try await setUserSMPServers(smpServers: smpServers)
DispatchQueue.main.async {
chatModel.userSMPServers = smpServers
if smpServers.isEmpty {
isUserSMPServers = false
editSMPServers = true
} else {
editSMPServers = false
}
}
} catch {
let err = error.localizedDescription
logger.error("SMPServers.saveServers setUserSMPServers error: \(err)")
DispatchQueue.main.async {
showBadServersAlert = true
}
}
}
}
func howToButton() -> some View {
Button {
DispatchQueue.main.async {
UIApplication.shared.open(howToUrl)
}
} label: {
HStack{
Text("How to")
Image(systemName: "arrow.up.right.circle")
}
}
}
}
// TODO preview doesn't work
struct SMPServers_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.currentUser = User.sampleData
chatModel.userSMPServers = []
return SMPServers()
.environmentObject(chatModel)
}
}
@@ -47,6 +47,19 @@ struct SettingsView: View {
}
}
}
Section("Settings") {
NavigationLink {
SMPServers()
.navigationTitle("Your SMP servers")
} label: {
HStack {
Image(systemName: "server.rack")
.padding(.trailing, 4)
Text("SMP servers")
}
}
}
Section("Help") {
NavigationLink {
+10 -16
View File
@@ -23,32 +23,26 @@ struct WelcomeView: View {
Text("You control your chat!")
.font(.title)
.padding(.bottom)
Text("The messaging and application platform protecting your privacy and security.")
Text("The messaging and application platform 100% private by design!")
.padding(.bottom, 8)
Text("Your profile, contacts and messages (once delivered) are only stored locally on your device.")
.padding(.bottom, 8)
Text("We don't store any of your contacts or messages (once delivered) on the servers.")
.padding(.bottom, 32)
Text("Create profile")
.font(.largeTitle)
.padding(.bottom)
Text("Your profile is stored on your device and shared only with your contacts.")
.padding(.bottom, 4)
Text("(shared only with your contacts)")
.padding(.bottom)
ZStack(alignment: .topLeading) {
if !validDisplayName(displayName) {
Button {
AlertManager.shared.showAlertMsg(
title: "Display name",
message: "Display name can't contain spaces"
)
} label: {
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
.padding(.top, 4)
}
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
.padding(.top, 4)
}
TextField("Display name", text: $displayName)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.padding(.leading, 28)
.padding(.bottom, 2)
}
.padding(.bottom)
TextField("Full name (optional)", text: $fullName)
@@ -68,7 +62,7 @@ struct WelcomeView: View {
fatalError("Failed to create user: \(error)")
}
}
.disabled(!validDisplayName(displayName))
.disabled(!validDisplayName(displayName) || displayName == "")
}
}
.padding()
+42 -36
View File
@@ -13,16 +13,6 @@
5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
5C27D00827C7D8B500DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00327C7D8B500DD6182 /* libgmpxx.a */; };
5C27D00927C7D8B500DD6182 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00327C7D8B500DD6182 /* libgmpxx.a */; };
5C27D00A27C7D8B500DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00427C7D8B500DD6182 /* libgmp.a */; };
5C27D00B27C7D8B500DD6182 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00427C7D8B500DD6182 /* libgmp.a */; };
5C27D00C27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */; };
5C27D00D27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */; };
5C27D00E27C7D8B500DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00627C7D8B500DD6182 /* libffi.a */; };
5C27D00F27C7D8B500DD6182 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00627C7D8B500DD6182 /* libffi.a */; };
5C27D01027C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */; };
5C27D01127C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */; };
5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
5C2E260827A2941F00F70299 /* SimpleXAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260627A2941F00F70299 /* SimpleXAPI.swift */; };
5C2E260B27A30CFA00F70299 /* ChatListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C2E260A27A30CFA00F70299 /* ChatListView.swift */; };
@@ -55,6 +45,16 @@
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C764E7C279C71DB000C6508 /* libz.tbd */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C79C23E27DB673900C829D6 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23927DB673800C829D6 /* libffi.a */; };
5C79C23F27DB673900C829D6 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23927DB673800C829D6 /* libffi.a */; };
5C79C24027DB673900C829D6 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23A27DB673800C829D6 /* libgmp.a */; };
5C79C24127DB673900C829D6 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23A27DB673800C829D6 /* libgmp.a */; };
5C79C24227DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; };
5C79C24327DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */; };
5C79C24427DB673900C829D6 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23C27DB673900C829D6 /* libgmpxx.a */; };
5C79C24527DB673900C829D6 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23C27DB673900C829D6 /* libgmpxx.a */; };
5C79C24627DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; };
5C79C24727DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */; };
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */; };
@@ -102,6 +102,8 @@
5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; };
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407827ADB701007B033A /* EmojiItemView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -125,11 +127,6 @@
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRequestView.swift; sourceTree = "<group>"; };
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemView.swift; sourceTree = "<group>"; };
5C27D00327C7D8B500DD6182 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C27D00427C7D8B500DD6182 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a"; sourceTree = "<group>"; };
5C27D00627C7D8B500DD6182 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a"; sourceTree = "<group>"; };
5C2E260627A2941F00F70299 /* SimpleXAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimpleXAPI.swift; sourceTree = "<group>"; };
5C2E260927A2C63500F70299 /* MyPlayground.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; path = MyPlayground.playground; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
5C2E260A27A30CFA00F70299 /* ChatListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatListView.swift; sourceTree = "<group>"; };
@@ -150,6 +147,11 @@
5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "SimpleX (macOS)-Bridging-Header.h"; sourceTree = "<group>"; };
5C764E7F279C7276000C6508 /* dummy.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = dummy.m; sourceTree = "<group>"; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C79C23927DB673800C829D6 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C79C23A27DB673800C829D6 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a"; sourceTree = "<group>"; };
5C79C23C27DB673900C829D6 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a"; sourceTree = "<group>"; };
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoView.swift; sourceTree = "<group>"; };
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoImage.swift; sourceTree = "<group>"; };
5C9FD96A27A56D4D0075386C /* JSON.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSON.swift; sourceTree = "<group>"; };
@@ -179,6 +181,7 @@
5CE4407127ADB1D0007B033A /* Emoji.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emoji.swift; sourceTree = "<group>"; };
5CE4407527ADB66A007B033A /* TextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextItemView.swift; sourceTree = "<group>"; };
5CE4407827ADB701007B033A /* EmojiItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiItemView.swift; sourceTree = "<group>"; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -186,14 +189,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C27D00827C7D8B500DD6182 /* libgmpxx.a in Frameworks */,
5C27D01027C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */,
5C27D00A27C7D8B500DD6182 /* libgmp.a in Frameworks */,
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */,
5C79C24027DB673900C829D6 /* libgmp.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
5C27D00E27C7D8B500DD6182 /* libffi.a in Frameworks */,
5C27D00C27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
5C79C23E27DB673900C829D6 /* libffi.a in Frameworks */,
5C79C24227DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */,
5C79C24627DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */,
5C79C24427DB673900C829D6 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -201,13 +204,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C27D00927C7D8B500DD6182 /* libgmpxx.a in Frameworks */,
5C27D00F27C7D8B500DD6182 /* libffi.a in Frameworks */,
5C79C24527DB673900C829D6 /* libgmpxx.a in Frameworks */,
5C79C24727DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a in Frameworks */,
5C764E85279C748C000C6508 /* libz.tbd in Frameworks */,
5C27D00B27C7D8B500DD6182 /* libgmp.a in Frameworks */,
5C27D01127C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a in Frameworks */,
5C27D00D27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */,
5C79C23F27DB673900C829D6 /* libffi.a in Frameworks */,
5C79C24127DB673900C829D6 /* libgmp.a in Frameworks */,
5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */,
5C79C24327DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -259,11 +262,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
5C27D00627C7D8B500DD6182 /* libffi.a */,
5C27D00427C7D8B500DD6182 /* libgmp.a */,
5C27D00327C7D8B500DD6182 /* libgmpxx.a */,
5C27D00727C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY-ghc8.10.7.a */,
5C27D00527C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a */,
5C79C23927DB673800C829D6 /* libffi.a */,
5C79C23A27DB673800C829D6 /* libgmp.a */,
5C79C23C27DB673900C829D6 /* libgmpxx.a */,
5C79C23D27DB673900C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2-ghc8.10.7.a */,
5C79C23B27DB673800C829D6 /* libHSsimplex-chat-1.3.1-9p94HNy0jcDHEpXhSSIQi2.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -385,6 +388,7 @@
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
640F50E227CF991C001E05C2 /* SMPServers.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -587,6 +591,7 @@
5C764E80279C7276000C6508 /* dummy.m in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */,
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -631,6 +636,7 @@
5C764E81279C7276000C6508 /* dummy.m in Sources */,
5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */,
640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */,
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -815,7 +821,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -825,7 +831,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -835,7 +841,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
MARKETING_VERSION = 0.4;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -855,7 +861,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 26;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -865,7 +871,7 @@
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait;
INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
@@ -875,7 +881,7 @@
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = "$(PROJECT_DIR)/Libraries/ios";
"LIBRARY_SEARCH_PATHS[sdk=iphonesimulator*]" = "$(PROJECT_DIR)/Libraries/sim";
MARKETING_VERSION = 0.4;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
+66
View File
@@ -0,0 +1,66 @@
# SimpleX announces SimpleX Chat mobile apps for iOS and Android
**Published:** March 8, 2022
## SimpleX Chat is the first chat platform that is 100% private by design - it has no access to your connections graph
We have now released iPhone and Android apps to [Apple AppStore](https://apps.apple.com/us/app/simplex-chat/id1605771084) and [Google Play Store](https://play.google.com/store/apps/details?id=chat.simplex.app), [APK for Android](https://github.com/simplex-chat/website/raw/master/simplex.apk) is also available for direct download.
**Please note**: the current version is only supported on iPhone 8+ and on Android 10+ - we are planning to add support for iPad and older devices very soon, and we will announce it on our [Reddit](https://www.reddit.com/r/SimpleXChat/) and [Twitter](https://twitter.com/SimpleXChat) channels - please subscribe to follow our updates there.
## What is SimpleX
We are building a new platform for distributed Internet applications where privacy of the messages _and_ the network matter.
We aim to provide the best possible protection of messages and metadata. Today there is no messaging application that works without global user identities, so we believe we provide better metadata privacy than alternatives. SimpleX is designed to be truly distributed with no central server, and without any global user identities. This allows for high 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. 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.
Further details on platform objectives and technical design are available [in SimpleX platform overview](https://github.com/simplex-chat/simplexmq/blob/master/protocol/overview-tjr.md).
## Why we are building it
Evgeny (SimpleX Chat founder): I have been working on this platform for a long time to provide a place where all people can communicate freely with each other, without fear of persecution because of what they said and who they are connected with. Not sharing information about your connections is very important, particularly for people living in oppressive regimes. Because of the terrible conflict between Russia and Ukraine, people of both countries I have friends and family there could be at risk when sharing their opinions or just from being connected to people who were prosecuted. Every messenger app that knows who you are can end up sharing all of your connections with undesirable third parties, either as a result of a court order or as a result of attack - so even Signal, which has strong encryption, cannot protect your connection graph. I hope our messenger can help people living in the oppressive regimes to express their opinions without fear and risk of prosecution.
## Huge thanks to our testers!
Thanks a lot to everybody who helped testing and improving the apps!
If you have a [TestFlight version](https://testflight.apple.com/join/DWuT2LQu) installed you can continue using it.
We plan to keep it as stable as we can, and it will give you access to all new features 1-2 weeks earlier - it's limited to 10,000 users, so you can grab it while it's available. You can still communicate with people who use a public version we are committed to maintaining backwards compatibility.
You can always migrate from a public App Store version to a TestFlight version. The opposite migration - from TestFlight to public version - is only possible when we have the same app versions released, as there are usually some database migrations that cannot be reversed. To migrate to public version you have to disable automatic updates on TestFlight, wait until public version catches up and then install it from App Store. In any case, it is safe installing the public version, but it might crash if you have a newer version from TestFlight - in this case you just need to re-install the app from TestFlight and install App Store version a bit later - you would not lose any of your data.
## It's not all new - our core code has been used for a long time by a few thousand people in our terminal app.
The apps use the same core code as our terminal app, that was used and stabilized over a long time, and it provides the same level of privacy and security that has been available since the release of v1 earlier this year:
- [double-ratchet](https://www.signal.org/docs/specifications/doubleratchet/) E2E encryption.
- separate keys for each contact.
- additional layer of E2E encryption in each message queue (to prevent traffic correlation when multiple queues are used in a conversation - something we plan later this year).
- additional encryption of messages delivered from servers to recipients (also to prevent traffic correlation).
You can read more technical details in our recent [v1 announcement](https://github.com/simplex-chat/simplex-chat/blob/stable/blog/20220112-simplex-chat-v1-released.md).
A big thank you to [@angerman](https://github.com/angerman) for making it possible to compile our Haskell code to mobile platforms and getting it approved on app stores - it has been a non-trivial project, and it is still ongoing.
## Install the apps and make a private connection!
Once you install the app, you can connect to anybody:
1. Create your local chat profile - it is not shared with SimpleX servers, it is local to your devices, and it will be shared with your contacts when you connect.
2. To make a private connection, you need to create a one-time connection link / QR code via "Add contact" button in the app. You can either show the QR code to your contact in person or via a video call - this is the most secure way to create a connection - or you can share the link via any other channel - only one user can connect via this link.
3. Once another user scans the QR code or opens the app via the link (they also should create their profile first) the connection will be created and you can send e2e encrypted messages privately, without anybody knowing you are connected.
## New features and improvements that are coming soon
- push notification server. Currently the apps load messages in the background periodically, that can be quite infrequent on iOS if you don't open the app regularly. With push notifications you would know about the new messages instantly.
- e2e encrypted audio and video calls via WebRTC.
- export and import of the chat database.
- "reply to message" - feature allowing you to quote the message you are replying to.
- localization - we will let you know once you can contribute the translations to your languages.
- configuring your servers in the apps - this will be released this week, both for iOS and Android. By default the apps are using SimpleX Chat servers, but you will be able to configure your own and still be connected to other users who use our app with our servers.
- user profile images.
- sending images and files - image preview will be sent via the servers, so it can be asynchronous, and large files/full resolution images via WebRTC, so both devices will have to be online.
Please let us know what else you think is important and if you find any bugs.
+1 -1
View File
@@ -3,7 +3,7 @@ packages: .
source-repository-package
type: git
location: git://github.com/simplex-chat/simplexmq.git
tag: d1e6147adfbd46f5e3e996cc6365d8f3f0f7669c
tag: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc
source-repository-package
type: git
+30
View File
@@ -0,0 +1,30 @@
diff --git a/cbits/random_initialized.c b/cbits/random_initialized.c
index 36ac968..ab708b0 100644
--- a/cbits/random_initialized.c
+++ b/cbits/random_initialized.c
@@ -5,14 +5,6 @@
#include <sys/stat.h>
#include <unistd.h>
-#ifdef HAVE_GETENTROPY
-static int ensure_pool_initialized_getentropy()
-{
- char tmp;
- return getentropy(&tmp, sizeof(tmp));
-}
-#endif
-
// Poll /dev/random to wait for randomness. This is a proxy for the /dev/urandom
// pool being initialized.
static int ensure_pool_initialized_poll()
@@ -45,10 +37,5 @@ static int ensure_pool_initialized_poll()
// Returns 0 on success, non-zero on failure.
int ensure_pool_initialized()
{
-#ifdef HAVE_GETENTROPY
- if (ensure_pool_initialized_getentropy() == 0)
- return 0;
-#endif
-
return ensure_pool_initialized_poll();
}
Generated
+39 -39
View File
@@ -20,10 +20,10 @@
"flake": false,
"locked": {
"lastModified": 1603716527,
"narHash": "sha256-sDbrmur9Zfp4mPKohCD8IDZfXJ0Tjxpmr2R+kg5PpSY=",
"narHash": "sha256-X0TFfdD4KZpwl0Zr6x+PLxUt/VyKQfX7ylXHdmZIL+w=",
"owner": "haskell",
"repo": "cabal",
"rev": "94aaa8e4720081f9c75497e2735b90f6a819b08e",
"rev": "48bf10787e27364730dd37a42b603cee8d6af7ee",
"type": "github"
},
"original": {
@@ -36,11 +36,11 @@
"cabal-34": {
"flake": false,
"locked": {
"lastModified": 1622475795,
"narHash": "sha256-chwTL304Cav+7p38d9mcb+egABWmxo2Aq+xgVBgEb/U=",
"lastModified": 1640353650,
"narHash": "sha256-N1t6M3/wqj90AEdRkeC8i923gQYUpzSr8b40qVOZ1Rk=",
"owner": "haskell",
"repo": "cabal",
"rev": "b086c1995cdd616fc8d91f46a21e905cc50a1049",
"rev": "942639c18c0cd8ec53e0a6f8d120091af35312cd",
"type": "github"
},
"original": {
@@ -53,11 +53,11 @@
"cabal-36": {
"flake": false,
"locked": {
"lastModified": 1640163203,
"narHash": "sha256-TwDWP2CffT0j40W6zr0J1Qbu+oh3nsF1lUx9446qxZM=",
"lastModified": 1641652457,
"narHash": "sha256-BlFPKP4C4HRUJeAbdembX1Rms1LD380q9s0qVDeoAak=",
"owner": "haskell",
"repo": "cabal",
"rev": "ecf418050c1821f25e2e218f1be94c31e0465df1",
"rev": "f27667f8ec360c475027dcaee0138c937477b070",
"type": "github"
},
"original": {
@@ -100,11 +100,11 @@
},
"flake-utils_2": {
"locked": {
"lastModified": 1623875721,
"narHash": "sha256-A8BU7bjS5GirpAUv4QA+QnJ4CceLHkcXdRp4xITDB0s=",
"lastModified": 1644229661,
"narHash": "sha256-1YdnJAsNy69bpcjuoKdOYQX0YxZBiCYZo4Twxerqv7k=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f7e004a55b120c02ecb6219596820fcd32ca8772",
"rev": "3cecb5b042f7f209c56ffd8371b2711a290ec797",
"type": "github"
},
"original": {
@@ -133,11 +133,11 @@
"hackage": {
"flake": false,
"locked": {
"lastModified": 1642986764,
"narHash": "sha256-U6FPiNjz9JctwKC838LEoT/xjGfb8L18ZGIEY5YYzdU=",
"lastModified": 1646625282,
"narHash": "sha256-U23F/EXZC1UOxO3SkfzS82TwYtT42sp5Y6BImXsHWMo=",
"owner": "input-output-hk",
"repo": "hackage.nix",
"rev": "22406c79a506164c4e835a68e54739f63f918784",
"rev": "bff4ab542bc6f68fc078ccd0df2e8eae61650e32",
"type": "github"
},
"original": {
@@ -169,16 +169,16 @@
"stackage": "stackage"
},
"locked": {
"lastModified": 1643019329,
"narHash": "sha256-So77czYvvD0jt4GJeypkqw3VNn20ype5tHnHri2s5lg=",
"lastModified": 1646643560,
"narHash": "sha256-mCzOavKLzXof7NuTBGQx+KWX2AIarrxxGykBE4OvjzY=",
"owner": "input-output-hk",
"repo": "haskell.nix",
"rev": "ddc654e2e7e44617bfc17a5aed2a0947d3e192cc",
"rev": "98de1769b4d5d9a4d137a77c5ec153c900ab0fa5",
"type": "github"
},
"original": {
"owner": "input-output-hk",
"ref": "angerman/android-static",
"ref": "angerman/try-no-libcharset",
"repo": "haskell.nix",
"type": "github"
}
@@ -202,11 +202,11 @@
"nix-tools": {
"flake": false,
"locked": {
"lastModified": 1636018067,
"narHash": "sha256-ng306fkuwr6V/malWtt3979iAC4yMVDDH2ViwYB6sQE=",
"lastModified": 1644395812,
"narHash": "sha256-BVFk/BEsTLq5MMZvdy3ZYHKfaS3dHrsKh4+tb5t5b58=",
"owner": "input-output-hk",
"repo": "nix-tools",
"rev": "ed5bd7215292deba55d6ab7a4e8c21f8b1564dda",
"rev": "d847c63b99bbec78bf83be2a61dc9f09b8a9ccc1",
"type": "github"
},
"original": {
@@ -217,16 +217,16 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1641457028,
"narHash": "sha256-bA31xSpdSIo+rJMbHPurlxIsP/b6bbN+jvXOqyn2lR8=",
"owner": "angerman",
"lastModified": 1645623357,
"narHash": "sha256-vAaI91QFn/kY/uMiebW+kG2mPmxirMSJWYtkqkBKdDc=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "7b049e87e9b371f9ea6648aa8f1f2d17b2e31ae5",
"rev": "9222ae36b208d1c6b55d88e10aa68f969b5b5244",
"type": "github"
},
"original": {
"owner": "angerman",
"ref": "patch-1",
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
@@ -249,11 +249,11 @@
},
"nixpkgs-2105": {
"locked": {
"lastModified": 1640283157,
"narHash": "sha256-6Ddfop+rKE+Gl9Tjp9YIrkfoYPzb8F80ergdjcq3/MY=",
"lastModified": 1642244250,
"narHash": "sha256-vWpUEqQdVP4srj+/YLJRTN9vjpTs4je0cdWKXPbDItc=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "dde1557825c5644c869c5efc7448dc03722a8f09",
"rev": "0fd9ee1aa36ce865ad273f4f07fdc093adeb5c00",
"type": "github"
},
"original": {
@@ -265,11 +265,11 @@
},
"nixpkgs-2111": {
"locked": {
"lastModified": 1640283207,
"narHash": "sha256-SCwl7ZnCfMDsuSYvwIroiAlk7n33bW8HFfY8NvKhcPA=",
"lastModified": 1644510859,
"narHash": "sha256-xjpVvL5ecbyi0vxtVl/Fh9bwGlMbw3S06zE5nUzFB8A=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "64c7e3388bbd9206e437713351e814366e0c3284",
"rev": "0d1d5d7e3679fec9d07f2eb804d9f9fdb98378d3",
"type": "github"
},
"original": {
@@ -281,11 +281,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1641285291,
"narHash": "sha256-KYaOBNGar3XWTxTsYPr9P6u74KAqNq0wobEC236U+0c=",
"lastModified": 1644486793,
"narHash": "sha256-EeijR4guVHgVv+JpOX3cQO+1XdrkJfGmiJ9XVsVU530=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0432195a4b8d68faaa7d3d4b355260a3120aeeae",
"rev": "1882c6b7368fd284ad01b0a5b5601ef136321292",
"type": "github"
},
"original": {
@@ -322,11 +322,11 @@
"stackage": {
"flake": false,
"locked": {
"lastModified": 1642986888,
"narHash": "sha256-oxG7LzlJdjKTJgSv7diKWsGTETDZMPT2mNNLbrBfiVs=",
"lastModified": 1646625386,
"narHash": "sha256-dIsnm5vx9Dlxx/rRjFyO7uMBfKjEN6RX7oAenwfetHY=",
"owner": "input-output-hk",
"repo": "stackage.nix",
"rev": "aeaf5fe21874f01702f394d01e18f472be6e3e08",
"rev": "e6a7664a79ed4ec8a19d76fb60731190b8763874",
"type": "github"
},
"original": {
+166 -71
View File
@@ -1,7 +1,7 @@
{
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.nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; #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/try-no-libcharset";
inputs.haskellNix.inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, haskellNix, nixpkgs, flake-utils }:
@@ -21,6 +21,7 @@
sha256map = import ./sha256map.nix;
modules = [{
packages.direct-sqlite.patches = [ ./direct-sqlite-2.3.26.patch ];
packages.entropy.patches = [ ./entropy.patch ];
}
({ pkgs,lib, ... }: lib.mkIf (pkgs.stdenv.hostPlatform.isAndroid) {
packages.simplex-chat.components.library.ghcOptions = [ "-pie" ];
@@ -45,76 +46,170 @@
"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-android-libsupport.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-android-libsimplex.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
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-aarch64-android-libsupport.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-aarch64-android-libsimplex.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
"x86_64-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-x86_64-android-libsupport.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
"x86_64-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-x86_64-android-libsimplex.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
"x86_64-linux: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-x86_64-linux-libsupport.zip *)
rm -fR $out/_pkg
mkdir -p $out/nix-support
echo "file binary-dist \"$(echo $out/*.zip)\"" \
> $out/nix-support/hydra-build-products
'';
};
"x86_64-linux: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-x86_64-linux-libsimplex.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;
+1 -1
View File
@@ -1,5 +1,5 @@
name: simplex-chat
version: 1.3.0
version: 1.3.2
#synopsis:
#description:
homepage: https://github.com/simplex-chat/simplex-chat#readme
+29
View File
@@ -0,0 +1,29 @@
# Server configuration
- in agent:
- Agent.Env.SQLite - move smpServers from AgentConfig to Env, make it TVar; keep "initialSmpServers" in AgentConfig?
- Agent - getSMPServer to read servers from Env and choose a random server
- Agent - new functional api - "useServers"
- ~~Agent.Protocol - new ACommand?~~
- chat core:
- db:
- new table `smp_servers`, server per row, same columns as for agent. Have rowid for future
- getServers method
- update - truncate and rewrite
- ChatCommand GetServers - new ChatResponse with list of user SMPServers, it may be empty if default are used
- ChatCommand SetServers - ChatResponse Ok (restore default servers is empty set servers list)
- agent config is populated using getServers, if it's empty default are used
- mobile chat:
- mobileChatOpts to be populated with initial servers on init (getServers or default if empty)
- in ui:
- view in settings
- GetServers on view open to populate
- Confirm buttons, Restore button - destructive - clears user servers and default are used
- validation
- validation on submit, error with server's string
- ~~TBD real-time validation~~
- ~~fastest is validation on submit without detailed error?~~
- ~~maybe even faster - alternatively have 3 fields for entry per server - fingerprint, host, port - and build server strings (still validate to avoid hard crash?)?~~
- terminal chat:
- if -s option is given, these servers are used and getServers is not used for populating agentConfig
- if -s option is not provided - same as in mobile - getServers or default if empty
+14
View File
@@ -0,0 +1,14 @@
# Include (Optional) Images in User Profiles
1. Add SQL migration for database in `src/Simplex/Chat/Migrations`
- This will touch `contact_profiles` and `group_profiles`
2. Add field to `User` in `Types.hs` allowing for null entry using `Maybe`
3. Extend parsing in `Chat.hs` under `chatCommandP :: Parser ChatCommand`
4. Update `UpdateProfile` in `Chat.hs` to accept possible display picture and implement an `APIUpdateProfile` command which accepts a JSON string `/_profile{...}` which will add the image to a profile.
5. Connect up to Android and iOS apps (new PRs)
Profile images will be base 64 encoded images. We can use the `base64P` parser to process them and pass them as JSON.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"git://github.com/simplex-chat/simplexmq.git"."d1e6147adfbd46f5e3e996cc6365d8f3f0f7669c" = "11wny0ivhrrp36757i074ml18k6nv7hq6a5dvv4rg3npqf19y3r7";
"git://github.com/simplex-chat/simplexmq.git"."5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc" = "0qjmldlrxl5waqfbsckjhxkd3zn25bkbyqwf9l0r4gq3c7l6k358";
"git://github.com/simplex-chat/aeson.git"."3eb66f9a68f103b5f1489382aad89f5712a64db7" = "0kilkx59fl6c3qy3kjczqvm8c3f4n3p0bdk9biyflf51ljnzp4yp";
"git://github.com/simplex-chat/haskell-terminal.git"."f708b00009b54890172068f168bf98508ffcd495" = "0zmq7lmfsk8m340g47g5963yba7i88n4afa6z93sg9px5jv1mijj";
"git://github.com/zw3rk/android-support.git"."3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb" = "1r6jyxbim3dsvrmakqfyxbd6ms6miaghpbwyl0sr6dzwpgaprz97";
+3 -1
View File
@@ -5,7 +5,7 @@ cabal-version: 1.12
-- see: https://github.com/sol/hpack
name: simplex-chat
version: 1.3.0
version: 1.3.2
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -29,6 +29,8 @@ library
Simplex.Chat.Migrations.M20220205_chat_item_status
Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
Simplex.Chat.Migrations.M20220224_messages_fks
Simplex.Chat.Migrations.M20220301_smp_servers
Simplex.Chat.Migrations.M20220302_profile_images
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.Protocol
+71 -24
View File
@@ -22,15 +22,18 @@ import Crypto.Random (drgNew)
import Data.Attoparsec.ByteString.Char8 (Parser)
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.Bifunctor (first)
import qualified Data.ByteString.Base64 as B64
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Char (isSpace)
import Data.Functor (($>))
import Data.Int (Int64)
import Data.List (find)
import Data.List.NonEmpty (NonEmpty, nonEmpty)
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (isJust, mapMaybe)
import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, getCurrentTime)
@@ -39,7 +42,7 @@ import Data.Word (Word32)
import Simplex.Chat.Controller
import Simplex.Chat.Markdown
import Simplex.Chat.Messages
import Simplex.Chat.Options (ChatOpts (..))
import Simplex.Chat.Options (ChatOpts (..), smpServersP)
import Simplex.Chat.Protocol
import Simplex.Chat.Store
import Simplex.Chat.Types
@@ -50,7 +53,7 @@ import Simplex.Messaging.Agent.Protocol
import qualified Simplex.Messaging.Crypto as C
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Parsers (parseAll)
import Simplex.Messaging.Parsers (base64P, parseAll)
import Simplex.Messaging.Protocol (ErrorType (..), MsgBody)
import qualified Simplex.Messaging.Protocol as SMP
import Simplex.Messaging.Util (tryError)
@@ -71,8 +74,8 @@ defaultChatConfig =
{ agentConfig =
defaultAgentConfig
{ tcpPort = undefined, -- agent does not listen to TCP
smpServers = undefined, -- filled in from options
dbFile = undefined, -- filled in from options
initialSMPServers = undefined, -- filled in newChatController
dbFile = undefined, -- filled in newChatController
dbPoolSize = 1,
yesToMigrations = False
},
@@ -85,6 +88,14 @@ defaultChatConfig =
testView = False
}
defaultSMPServers :: NonEmpty SMPServer
defaultSMPServers =
L.fromList
[ "smp://0YuTwO05YJWS8rkjn9eLJDjQhFKvIYd8d4xG8X1blIU=@smp8.simplex.im",
"smp://SkIkI6EPd2D63F4xFKfHk7I1UGZVNn6k1QWZ5rcyr6w=@smp9.simplex.im",
"smp://6iIcWT_dF2zN_w5xzZEY7HI2Prbh3ldP07YTyDexPjE=@smp10.simplex.im"
]
logCfg :: LogConfig
logCfg = LogConfig {lc_file = Nothing, lc_stderr = True}
@@ -95,7 +106,8 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch
activeTo <- newTVarIO ActiveNone
firstTime <- not <$> doesFileExist f
currentUser <- newTVarIO user
smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", smpServers}
initialSMPServers <- resolveServers
smpAgent <- getSMPAgentClient aCfg {dbFile = dbFilePrefix <> "_agent.db", initialSMPServers}
agentAsync <- newTVarIO Nothing
idsDrg <- newTVarIO =<< drgNew
inputQ <- newTBQueueIO tbqSize
@@ -105,6 +117,13 @@ newChatController chatStore user cfg@ChatConfig {agentConfig = aCfg, tbqSize} Ch
sndFiles <- newTVarIO M.empty
rcvFiles <- newTVarIO M.empty
pure ChatController {activeTo, firstTime, currentUser, smpAgent, agentAsync, chatStore, idsDrg, inputQ, outputQ, notifyQ, chatLock, sndFiles, rcvFiles, config, sendNotification}
where
resolveServers :: IO (NonEmpty SMPServer)
resolveServers = case user of
Nothing -> pure $ if null smpServers then defaultSMPServers else L.fromList smpServers
Just usr -> do
userSmpServers <- getSMPServers chatStore usr
pure . fromMaybe defaultSMPServers . nonEmpty $ if null smpServers then userSmpServers else smpServers
runChatController :: (MonadUnliftIO m, MonadReader ChatController m) => User -> m ()
runChatController = race_ notificationSubscriber . agentSubscriber
@@ -144,7 +163,10 @@ processChatCommand = \case
user <- withStore $ \st -> createUser st p True
atomically . writeTVar u $ Just user
pure $ CRActiveUser user
StartChat -> withUser' $ \user -> startChatController user $> CRChatStarted
StartChat -> withUser' $ \user ->
asks agentAsync >>= readTVarIO >>= \case
Just _ -> pure CRChatRunning
_ -> startChatController user $> CRChatStarted
APIGetChats -> CRApiChats <$> withUser (\user -> withStore (`getChatPreviews` user))
APIGetChat cType cId pagination -> withUser $ \user -> case cType of
CTDirect -> CRApiChat . AChat SCTDirect <$> withStore (\st -> getDirectChat st user cId pagination)
@@ -193,6 +215,11 @@ processChatCommand = \case
`E.finally` deleteContactRequest st userId connReqId
withAgent $ \a -> rejectContact a connId invId
pure $ CRContactRequestRejected cReq
GetUserSMPServers -> CRUserSMPServers <$> withUser (\user -> withStore (`getSMPServers` user))
SetUserSMPServers smpServers -> withUser $ \user -> withChatLock $ do
withStore $ \st -> overwriteSMPServers st user smpServers
withAgent $ \a -> setSMPServers a (fromMaybe defaultSMPServers (nonEmpty smpServers))
pure CRCmdOk
ChatHelp section -> pure $ CRChatHelp section
Welcome -> withUser $ pure . CRWelcome
AddContact -> withUser $ \User {userId} -> withChatLock . procCmd $ do
@@ -367,17 +394,12 @@ processChatCommand = \case
FileStatus fileId ->
CRFileTransferStatus <$> withUser (\User {userId} -> withStore $ \st -> getFileTransferProgress st userId fileId)
ShowProfile -> withUser $ \User {profile} -> pure $ CRUserProfile profile
UpdateProfile p@Profile {displayName} -> withUser $ \user@User {profile} ->
if p == profile
then pure CRUserProfileNoChange
else do
withStore $ \st -> updateUserProfile st user p
let user' = (user :: User) {localDisplayName = displayName, profile = p}
asks currentUser >>= atomically . (`writeTVar` Just user')
contacts <- withStore (`getUserContacts` user)
withChatLock . procCmd $ do
forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p
pure $ CRUserProfileUpdated profile p
UpdateProfile displayName fullName -> withUser $ \user@User {profile} -> do
let p = (profile :: Profile) {displayName = displayName, fullName = fullName}
updateProfile user p
UpdateProfileImage image -> withUser $ \user@User {profile} -> do
let p = (profile :: Profile) {image = Just image}
updateProfile user p
QuitChat -> liftIO exitSuccess
ShowVersion -> pure $ CRVersionInfo versionNumber
where
@@ -416,6 +438,18 @@ processChatCommand = \case
checkSndFile f = do
unlessM (doesFileExist f) . throwChatError $ CEFileNotFound f
(,) <$> getFileSize f <*> asks (fileChunkSize . config)
updateProfile :: User -> Profile -> m ChatResponse
updateProfile user@User {profile = p} p'@Profile {displayName} = do
if p' == p
then pure CRUserProfileNoChange
else do
withStore $ \st -> updateUserProfile st user p'
let user' = (user :: User) {localDisplayName = displayName, profile = p'}
asks currentUser >>= atomically . (`writeTVar` Just user')
contacts <- withStore (`getUserContacts` user)
withChatLock . procCmd $ do
forM_ contacts $ \ct -> sendDirectContactMessage ct $ XInfo p'
pure $ CRUserProfileUpdated p p'
getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath
getRcvFilePath fileId filePath fileName = case filePath of
Nothing -> do
@@ -528,7 +562,9 @@ subscribeUserConnections user@User {userId} = do
subscribe cId `catchError` (toView . CRRcvFileSubError ft)
subscribePendingConnections n = do
cs <- withStore (`getPendingConnections` user)
subscribeConns n cs `catchError` \_ -> pure ()
summary <- pooledForConcurrentlyN n cs $ \Connection {agentConnId = acId@(AgentConnId cId)} ->
PendingSubStatus acId <$> ((subscribe cId $> Nothing) `catchError` (pure . Just))
toView $ CRPendingSubSummary summary
subscribeUserContactLink n = do
cs <- withStore (`getUserContactLinkConnections` userId)
(subscribeConns n cs >> toView CRUserContactLinkSubscribed)
@@ -1351,7 +1387,7 @@ getCreateActiveUser st = do
loop = do
displayName <- getContactName
fullName <- T.pack <$> getWithPrompt "full name (optional)"
liftIO (runExceptT $ createUser st Profile {displayName, fullName} True) >>= \case
liftIO (runExceptT $ createUser st Profile {displayName, fullName, image = Nothing} True) >>= \case
Left SEDuplicateName -> do
putStrLn "chosen display name is already used by another profile on this device, choose another one"
loop
@@ -1421,6 +1457,8 @@ withStore ::
withStore action =
asks chatStore
>>= runExceptT . action
-- use this line instead of above to log query errors
-- >>= (\st -> runExceptT $ action st `E.catch` \(e :: E.SomeException) -> liftIO (print e) >> E.throwIO e)
>>= liftEither . first ChatErrorStore
chatCommandP :: Parser ChatCommand
@@ -1436,6 +1474,9 @@ chatCommandP =
<|> "/_delete " *> (APIDeleteChat <$> chatTypeP <*> A.decimal)
<|> "/_accept " *> (APIAcceptContact <$> A.decimal)
<|> "/_reject " *> (APIRejectContact <$> A.decimal)
<|> "/smp_servers default" $> SetUserSMPServers []
<|> "/smp_servers " *> (SetUserSMPServers <$> smpServersP)
<|> "/smp_servers" $> GetUserSMPServers
<|> ("/help files" <|> "/help file" <|> "/hf") $> ChatHelp HSFiles
<|> ("/help groups" <|> "/help group" <|> "/hg") $> ChatHelp HSGroups
<|> ("/help address" <|> "/ha") $> ChatHelp HSMyAddress
@@ -1468,11 +1509,14 @@ chatCommandP =
<|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName)
<|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown
<|> ("/welcome" <|> "/w") $> Welcome
<|> ("/profile " <|> "/p ") *> (UpdateProfile <$> userProfile)
<|> "/profile_image " *> (UpdateProfileImage . ProfileImage <$> imageP)
<|> ("/profile " <|> "/p ") *> (uncurry UpdateProfile <$> userNames)
<|> ("/profile" <|> "/p") $> ShowProfile
<|> ("/quit" <|> "/q" <|> "/exit") $> QuitChat
<|> ("/version" <|> "/v") $> ShowVersion
where
imagePrefix = (<>) <$> "data:" <*> ("image/png;base64," <|> "image/jpg;base64,")
imageP = safeDecodeUtf8 <$> ((<>) <$> imagePrefix <*> (B64.encode <$> base64P))
chatTypeP = A.char '@' $> CTDirect <|> A.char '#' $> CTGroup
chatPaginationP =
(CPLast <$ "count=" <*> A.decimal)
@@ -1482,14 +1526,17 @@ chatCommandP =
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
refChar c = c > ' ' && c /= '#' && c /= '@'
onOffP = ("on" $> True) <|> ("off" $> False)
userProfile = do
userNames = do
cName <- displayName
fullName <- fullNameP cName
pure Profile {displayName = cName, fullName}
pure (cName, fullName)
userProfile = do
(cName, fullName) <- userNames
pure Profile {displayName = cName, fullName, image = Nothing}
groupProfile = do
gName <- displayName
fullName <- fullNameP gName
pure GroupProfile {displayName = gName, fullName}
pure GroupProfile {displayName = gName, fullName, image = Nothing}
fullNameP name = do
n <- (A.space *> A.takeByteString) <|> pure ""
pure $ if B.null n then name else safeDecodeUtf8 n
+18 -2
View File
@@ -36,7 +36,7 @@ import System.IO (Handle)
import UnliftIO.STM
versionNumber :: String
versionNumber = "1.3.0"
versionNumber = "1.3.2"
versionStr :: String
versionStr = "SimpleX Chat v" <> versionNumber
@@ -95,6 +95,8 @@ data ChatCommand
| APIDeleteChat ChatType Int64
| APIAcceptContact Int64
| APIRejectContact Int64
| GetUserSMPServers
| SetUserSMPServers [SMPServer]
| ChatHelp HelpSection
| Welcome
| AddContact
@@ -125,7 +127,8 @@ data ChatCommand
| CancelFile FileTransferId
| FileStatus FileTransferId
| ShowProfile
| UpdateProfile Profile
| UpdateProfile ContactName Text
| UpdateProfileImage ProfileImage
| QuitChat
| ShowVersion
deriving (Show)
@@ -133,8 +136,10 @@ data ChatCommand
data ChatResponse
= CRActiveUser {user :: User}
| CRChatStarted
| CRChatRunning
| CRApiChats {chats :: [AChat]}
| CRApiChat {chat :: AChat}
| CRUserSMPServers {smpServers :: [SMPServer]}
| CRNewChatItem {chatItem :: AChatItem}
| CRChatItemUpdated {chatItem :: AChatItem}
| CRMsgIntegrityError {msgerror :: MsgErrorType} -- TODO make it chat item to support in mobile
@@ -204,6 +209,7 @@ data ChatResponse
| CRMemberSubError {groupInfo :: GroupInfo, contactName :: ContactName, chatError :: ChatError} -- TODO Contact? or GroupMember?
| CRMemberSubErrors {memberSubErrors :: [MemberSubError]}
| CRGroupSubscribed {groupInfo :: GroupInfo}
| CRPendingSubSummary {pendingSubStatus :: [PendingSubStatus]}
| CRSndFileSubError {sndFileTransfer :: SndFileTransfer, chatError :: ChatError}
| CRRcvFileSubError {rcvFileTransfer :: RcvFileTransfer, chatError :: ChatError}
| CRUserContactLinkSubscribed
@@ -236,6 +242,16 @@ data MemberSubError = MemberSubError
instance ToJSON MemberSubError where
toEncoding = J.genericToEncoding J.defaultOptions
data PendingSubStatus = PendingSubStatus
{ connId :: AgentConnId,
connError :: Maybe ChatError
}
deriving (Show, Generic)
instance ToJSON PendingSubStatus where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data ChatError
= ChatError {errorType :: ChatErrorType}
| ChatErrorAgent {agentError :: AgentErrorType}
+2 -1
View File
@@ -86,10 +86,11 @@ chatHelpInfo =
green "Create your address: " <> highlight "/address",
"",
green "Other commands:",
indent <> highlight "/help <topic> " <> " - help on: files, groups, address",
indent <> highlight "/help <topic> " <> " - help on: files, groups, address, smp_servers",
indent <> highlight "/profile " <> " - show / update user profile",
indent <> highlight "/delete <contact>" <> " - delete contact and all messages with them",
indent <> highlight "/contacts " <> " - list contacts",
indent <> highlight "/smp_servers " <> " - show / set custom SMP servers",
indent <> highlight "/markdown " <> " - supported markdown syntax",
indent <> highlight "/version " <> " - SimpleX Chat version",
indent <> highlight "/quit " <> " - quit chat",
@@ -0,0 +1,21 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220301_smp_servers where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220301_smp_servers :: Query
m20220301_smp_servers =
[sql|
CREATE TABLE smp_servers (
smp_server_id INTEGER PRIMARY KEY,
host TEXT NOT NULL,
port TEXT NOT NULL,
key_hash BLOB NOT NULL,
user_id INTEGER NOT NULL REFERENCES users ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (host, port)
);
|]
@@ -0,0 +1,13 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220302_profile_images where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220302_profile_images :: Query
m20220302_profile_images =
[sql|
ALTER TABLE contact_profiles ADD COLUMN image TEXT;
ALTER TABLE group_profiles ADD COLUMN image TEXT;
|]
+4 -3
View File
@@ -20,6 +20,7 @@ import Simplex.Chat.Controller
import Simplex.Chat.Options
import Simplex.Chat.Store
import Simplex.Chat.Types
import Simplex.Messaging.Agent.Env.SQLite (AgentConfig (yesToMigrations))
import Simplex.Messaging.Protocol (CorrId (..))
foreign export ccall "chat_init" cChatInit :: CString -> IO (StablePtr ChatController)
@@ -48,7 +49,7 @@ mobileChatOpts :: ChatOpts
mobileChatOpts =
ChatOpts
{ dbFilePrefix = "simplex_v1", -- two database files will be created: simplex_v1_chat.db and simplex_v1_agent.db
smpServers = defaultSMPServers,
smpServers = [],
logConnections = False,
logAgent = False
}
@@ -57,7 +58,7 @@ defaultMobileConfig :: ChatConfig
defaultMobileConfig =
defaultChatConfig
{ yesToMigrations = True,
agentConfig = agentConfig defaultChatConfig {yesToMigrations = True}
agentConfig = (agentConfig defaultChatConfig) {yesToMigrations = True}
}
type CJSONString = CString
@@ -68,7 +69,7 @@ getActiveUser_ st = find activeUser <$> getUsers st
chatInit :: String -> IO ChatController
chatInit dbFilePrefix = do
let f = chatStoreFile dbFilePrefix
chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations defaultMobileConfig)
chatStore <- createStore f (dbPoolSize defaultMobileConfig) (yesToMigrations (defaultMobileConfig :: ChatConfig))
user_ <- getActiveUser_ chatStore
newChatController chatStore user_ defaultMobileConfig mobileChatOpts {dbFilePrefix} (const $ pure ())
+10 -21
View File
@@ -3,14 +3,12 @@
module Simplex.Chat.Options
( ChatOpts (..),
getChatOpts,
defaultSMPServers,
smpServersP,
)
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.Chat.Controller (updateStr, versionStr)
import Simplex.Messaging.Agent.Protocol (SMPServer (..))
@@ -20,20 +18,11 @@ import System.FilePath (combine)
data ChatOpts = ChatOpts
{ dbFilePrefix :: String,
smpServers :: NonEmpty SMPServer,
smpServers :: [SMPServer],
logConnections :: Bool,
logAgent :: Bool
}
defaultSMPServers :: NonEmpty SMPServer
defaultSMPServers =
L.fromList
[ "smp://u2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU=@smp4.simplex.im",
"smp://hpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg=@smp5.simplex.im",
"smp://PQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo=@smp6.simplex.im"
-- "smp://Tn1b3Rr7_gErbVt2v50Y_T-PvUAi1BYAMS-62w-k9CI=@139.162.240.237"
]
chatOpts :: FilePath -> Parser ChatOpts
chatOpts appDir =
ChatOpts
@@ -46,14 +35,13 @@ chatOpts appDir =
<> showDefault
)
<*> option
parseSMPServer
parseSMPServers
( long "server"
<> short 's'
<> metavar "SERVER"
<> help
"Comma separated list of SMP server(s) to use \
\(default: smp4.simplex.im,smp5.simplex.im,smp6.simplex.im)"
<> value defaultSMPServers
"Comma separated list of SMP server(s) to use"
<> value []
)
<*> switch
( long "connections"
@@ -68,10 +56,11 @@ chatOpts appDir =
where
defaultDbFilePath = combine appDir "simplex_v1"
parseSMPServer :: ReadM (NonEmpty SMPServer)
parseSMPServer = eitherReader $ parseAll servers . B.pack
where
servers = L.fromList <$> strP `A.sepBy1` A.char ','
parseSMPServers :: ReadM [SMPServer]
parseSMPServers = eitherReader $ parseAll smpServersP . B.pack
smpServersP :: A.Parser [SMPServer]
smpServersP = strP `A.sepBy1` A.char ','
getChatOpts :: FilePath -> IO ChatOpts
getChatOpts appDir =
+13 -17
View File
@@ -15,10 +15,12 @@ module Simplex.Chat.Protocol where
import Control.Monad ((<=<))
import Data.Aeson (FromJSON, ToJSON, (.:), (.:?), (.=))
import qualified Data.Aeson as J
import qualified Data.Aeson.Encoding as JE
import qualified Data.Aeson.KeyMap as JM
import qualified Data.Aeson.Types as JT
import qualified Data.Attoparsec.ByteString.Char8 as A
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Database.SQLite.Simple.FromField (FromField (..))
@@ -107,26 +109,24 @@ instance ToJSON MsgContentType where
toJSON = strToJSON
toEncoding = strToJEncoding
-- TODO - include tag and original JSON into MCUnknown so that information is not lost
-- so when it serializes back it is the same as it was and chat upgrade makes it readable
data MsgContent = MCText Text | MCUnknown
data MsgContent = MCText Text | MCUnknown J.Value Text
deriving (Eq, Show)
msgContentText :: MsgContent -> Text
msgContentText = \case
MCText t -> t
MCUnknown -> unknownMsgType
MCUnknown _ t -> t
toMsgContentType :: MsgContent -> MsgContentType
toMsgContentType = \case
MCText _ -> MCText_
MCUnknown -> MCUnknown_
MCUnknown {} -> MCUnknown_
instance FromJSON MsgContent where
parseJSON (J.Object v) = do
parseJSON jv@(J.Object v) = do
v .: "type" >>= \case
MCText_ -> MCText <$> v .: "text"
MCUnknown_ -> pure MCUnknown
MCUnknown_ -> MCUnknown jv . fromMaybe unknownMsgType <$> v .:? "text"
parseJSON invalid =
JT.prependFailure "bad MsgContent, " (JT.typeMismatch "Object" invalid)
@@ -134,16 +134,12 @@ unknownMsgType :: Text
unknownMsgType = "unknown message type"
instance ToJSON MsgContent where
toJSON mc =
J.object $
("type" .= toMsgContentType mc) : case mc of
MCText t -> ["text" .= t]
MCUnknown -> ["text" .= unknownMsgType]
toEncoding mc =
J.pairs $
("type" .= toMsgContentType mc) <> case mc of
MCText t -> "text" .= t
MCUnknown -> "text" .= unknownMsgType
toJSON = \case
MCUnknown v _ -> v
MCText t -> J.object ["type" .= MCText_, "text" .= t]
toEncoding = \case
MCUnknown v _ -> JE.value v
MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t
data CMEventTag
= XMsgNew_
+124 -81
View File
@@ -121,6 +121,8 @@ module Simplex.Chat.Store
updateDirectChatItem,
updateDirectChatItemsRead,
updateGroupChatItemsRead,
getSMPServers,
overwriteSMPServers,
)
where
@@ -158,10 +160,12 @@ import Simplex.Chat.Migrations.M20220122_v1_1
import Simplex.Chat.Migrations.M20220205_chat_item_status
import Simplex.Chat.Migrations.M20220210_deduplicate_contact_requests
import Simplex.Chat.Migrations.M20220224_messages_fks
import Simplex.Chat.Migrations.M20220301_smp_servers
import Simplex.Chat.Migrations.M20220302_profile_images
import Simplex.Chat.Protocol
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe)
import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..))
import Simplex.Messaging.Agent.Protocol (AgentMsgId, ConnId, InvitationId, MsgMeta (..), SMPServer (..))
import Simplex.Messaging.Agent.Store.SQLite (SQLiteStore (..), createSQLiteStore, firstRow, withTransaction)
import Simplex.Messaging.Agent.Store.SQLite.Migrations (Migration (..))
import qualified Simplex.Messaging.Crypto as C
@@ -176,7 +180,9 @@ schemaMigrations =
("20220122_v1_1", m20220122_v1_1),
("20220205_chat_item_status", m20220205_chat_item_status),
("20220210_deduplicate_contact_requests", m20220210_deduplicate_contact_requests),
("20220224_messages_fks", m20220224_messages_fks)
("20220224_messages_fks", m20220224_messages_fks),
("20220301_smp_servers", m20220301_smp_servers),
("20220302_profile_images", m20220302_profile_images)
]
-- | The list of migrations in ascending order by date
@@ -205,7 +211,7 @@ insertedRowId db = fromOnly . head <$> DB.query_ db "SELECT last_insert_rowid()"
type StoreMonad m = (MonadUnliftIO m, MonadError StoreError m)
createUser :: StoreMonad m => SQLiteStore -> Profile -> Bool -> m User
createUser st Profile {displayName, fullName} activeUser =
createUser st Profile {displayName, fullName, image} activeUser =
liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do
currentTs <- getCurrentTime
DB.execute
@@ -219,8 +225,8 @@ createUser st Profile {displayName, fullName} activeUser =
(displayName, displayName, userId, currentTs, currentTs)
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
"INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
(displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -228,7 +234,7 @@ createUser st Profile {displayName, fullName} activeUser =
(profileId, displayName, userId, True, currentTs, currentTs)
contactId <- insertedRowId db
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName)
pure . Right $ toUser (userId, contactId, activeUser, displayName, fullName, image)
getUsers :: SQLiteStore -> IO [User]
getUsers st =
@@ -237,15 +243,15 @@ getUsers st =
<$> DB.query_
db
[sql|
SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name
SELECT u.user_id, u.contact_id, u.active_user, u.local_display_name, p.full_name, p.image
FROM users u
JOIN contacts c ON u.contact_id = c.contact_id
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
|]
toUser :: (UserId, Int64, Bool, ContactName, Text) -> User
toUser (userId, userContactId, activeUser, displayName, fullName) =
let profile = Profile {displayName, fullName}
toUser :: (UserId, Int64, Bool, ContactName, Text, Maybe ProfileImage) -> User
toUser (userId, userContactId, activeUser, displayName, fullName, image) =
let profile = Profile {displayName, fullName, image}
in User {userId, userContactId, localDisplayName = displayName, profile, activeUser}
setActiveUser :: MonadUnliftIO m => SQLiteStore -> UserId -> m ()
@@ -283,7 +289,7 @@ getConnReqContactXContactId st userId cReqHash = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -338,12 +344,12 @@ createDirectContact st userId activeConn@Connection {connId} profile =
pure $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup = Nothing, createdAt}
createContact_ :: DB.Connection -> UserId -> Int64 -> Profile -> Maybe Int64 -> UTCTime -> IO (Either StoreError (Text, Int64, Int64))
createContact_ db userId connId Profile {displayName, fullName} viaGroup currentTs =
createContact_ db userId connId Profile {displayName, fullName, image} viaGroup currentTs =
withLocalDisplayName db userId displayName $ \ldn -> do
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
"INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
(displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -420,13 +426,14 @@ updateContactProfile_ db userId contactId profile = do
updateContactProfile_' db userId contactId profile currentTs
updateContactProfile_' :: DB.Connection -> UserId -> Int64 -> Profile -> UTCTime -> IO ()
updateContactProfile_' db userId contactId Profile {displayName, fullName} updatedAt = do
updateContactProfile_' db userId contactId Profile {displayName, fullName, image} updatedAt = do
DB.executeNamed
db
[sql|
UPDATE contact_profiles
SET display_name = :display_name,
full_name = :full_name,
image = :image,
updated_at = :updated_at
WHERE contact_profile_id IN (
SELECT contact_profile_id
@@ -437,6 +444,7 @@ updateContactProfile_' db userId contactId Profile {displayName, fullName} updat
|]
[ ":display_name" := displayName,
":full_name" := fullName,
":image" := image,
":updated_at" := updatedAt,
":user_id" := userId,
":contact_id" := contactId
@@ -454,17 +462,17 @@ updateContact_ db userId contactId displayName newName updatedAt = do
(newName, updatedAt, userId, contactId)
DB.execute db "DELETE FROM display_names WHERE local_display_name = ? AND user_id = ?" (displayName, userId)
type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, UTCTime)
type ContactRow = (Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage, UTCTime)
toContact :: ContactRow :. ConnectionRow -> Contact
toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) =
let profile = Profile {displayName, fullName}
toContact ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) =
let profile = Profile {displayName, fullName, image}
activeConn = toConnection connRow
in Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
toContactOrError :: ContactRow :. MaybeConnectionRow -> Either StoreError Contact
toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, createdAt) :. connRow) =
let profile = Profile {displayName, fullName}
toContactOrError ((contactId, localDisplayName, viaGroup, displayName, fullName, image, createdAt) :. connRow) =
let profile = Profile {displayName, fullName, image}
in case toMaybeConnection connRow of
Just activeConn ->
Right Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
@@ -601,7 +609,7 @@ createOrUpdateContactRequest st userId userContactLinkId invId profile xContactI
createOrUpdateContactRequest_ db userId userContactLinkId invId profile xContactId_
createOrUpdateContactRequest_ :: DB.Connection -> UserId -> Int64 -> InvitationId -> Profile -> Maybe XContactId -> IO (Either StoreError (Either Contact UserContactRequest))
createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName} xContactId_ =
createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {displayName, fullName, image} xContactId_ =
maybeM getContact' xContactId_ >>= \case
Just contact -> pure . Right $ Left contact
Nothing -> Right <$$> createOrUpdate_
@@ -621,8 +629,8 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
createContactRequest_ currentTs ldn = do
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
"INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
(displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -642,7 +650,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -662,7 +670,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
@@ -685,6 +693,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
UPDATE contact_profiles
SET display_name = ?,
full_name = ?,
image = ?,
updated_at = ?
WHERE contact_profile_id IN (
SELECT contact_profile_id
@@ -693,7 +702,7 @@ createOrUpdateContactRequest_ db userId userContactLinkId invId Profile {display
AND contact_request_id = ?
)
|]
(ldn, fullName, updatedAt, userId, cReqId)
(ldn, fullName, image, updatedAt, userId, cReqId)
DB.execute
db
[sql|
@@ -720,7 +729,7 @@ getContactRequest_ db userId contactRequestId =
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
@@ -729,11 +738,11 @@ getContactRequest_ db userId contactRequestId =
|]
(userId, contactRequestId)
type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, UTCTime, Maybe XContactId)
type ContactRequestRow = (Int64, ContactName, AgentInvId, Int64, AgentConnId, Int64, ContactName, Text, Maybe ProfileImage, UTCTime, Maybe XContactId)
toContactRequest :: ContactRequestRow -> UserContactRequest
toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, createdAt, xContactId) = do
let profile = Profile {displayName, fullName}
toContactRequest (contactRequestId, localDisplayName, agentInvitationId, userContactLinkId, agentContactConnId, profileId, displayName, fullName, image, createdAt, xContactId) = do
let profile = Profile {displayName, fullName, image}
in UserContactRequest {contactRequestId, agentInvitationId, userContactLinkId, agentContactConnId, localDisplayName, profileId, profile, createdAt, xContactId}
getContactRequestIdByName :: StoreMonad m => SQLiteStore -> UserId -> ContactName -> m Int64
@@ -875,7 +884,7 @@ toMaybeConnection (Just connId, Just agentConnId, Just connLevel, viaContact, Ju
toMaybeConnection _ = Nothing
getMatchingContacts :: MonadUnliftIO m => SQLiteStore -> UserId -> Contact -> m [Contact]
getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName}} =
getMatchingContacts st userId Contact {contactId, profile = Profile {displayName, fullName, image}} =
liftIO . withTransaction st $ \db -> do
contactIds <-
map fromOnly
@@ -887,11 +896,13 @@ getMatchingContacts st userId Contact {contactId, profile = Profile {displayName
JOIN contact_profiles p ON ct.contact_profile_id = p.contact_profile_id
WHERE ct.user_id = :user_id AND ct.contact_id != :contact_id
AND p.display_name = :display_name AND p.full_name = :full_name
AND ((p.image IS NULL AND :image IS NULL) OR p.image = :image)
|]
[ ":user_id" := userId,
":contact_id" := contactId,
":display_name" := displayName,
":full_name" := fullName
":full_name" := fullName,
":image" := image
]
rights <$> mapM (getContact_ db userId) contactIds
@@ -1055,15 +1066,15 @@ getConnectionEntity st User {userId, userContactId} agentConnId =
<$> DB.query
db
[sql|
SELECT c.local_display_name, p.display_name, p.full_name, c.via_group, c.created_at
SELECT c.local_display_name, p.display_name, p.full_name, p.image, c.via_group, c.created_at
FROM contacts c
JOIN contact_profiles p ON c.contact_profile_id = p.contact_profile_id
WHERE c.user_id = ? AND c.contact_id = ?
|]
(userId, contactId)
toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe Int64, UTCTime)] -> Either StoreError Contact
toContact' contactId activeConn [(localDisplayName, displayName, fullName, viaGroup, createdAt)] =
let profile = Profile {displayName, fullName}
toContact' :: Int64 -> Connection -> [(ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime)] -> Either StoreError Contact
toContact' contactId activeConn [(localDisplayName, displayName, fullName, image, viaGroup, createdAt)] =
let profile = Profile {displayName, fullName, image}
in Right $ Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
toContact' _ _ _ = Left $ SEInternalError "referenced contact not found"
getGroupAndMember_ :: DB.Connection -> Int64 -> Connection -> ExceptT StoreError IO (GroupInfo, GroupMember)
@@ -1074,15 +1085,15 @@ getConnectionEntity st User {userId, userContactId} agentConnId =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name,
pu.display_name, pu.full_name, pu.image,
-- from GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image
FROM group_members m
JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
JOIN groups g ON g.group_id = m.group_id
@@ -1144,7 +1155,7 @@ updateConnectionStatus st Connection {connId} connStatus =
createNewGroup :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> GroupProfile -> m GroupInfo
createNewGroup st gVar user groupProfile =
liftIOEither . checkConstraint SEDuplicateName . withTransaction st $ \db -> do
let GroupProfile {displayName, fullName} = groupProfile
let GroupProfile {displayName, fullName, image} = groupProfile
uId = userId user
currentTs <- getCurrentTime
DB.execute
@@ -1153,8 +1164,8 @@ createNewGroup st gVar user groupProfile =
(displayName, displayName, uId, currentTs, currentTs)
DB.execute
db
"INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
"INSERT INTO group_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
(displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -1181,13 +1192,13 @@ createGroupInvitation st user@User {userId} contact@Contact {contactId} GroupInv
<$> DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId)
createGroupInvitation_ :: DB.Connection -> IO (Either StoreError GroupInfo)
createGroupInvitation_ db = do
let GroupProfile {displayName, fullName} = groupProfile
let GroupProfile {displayName, fullName, image} = groupProfile
withLocalDisplayName db userId displayName $ \localDisplayName -> do
currentTs <- getCurrentTime
DB.execute
db
"INSERT INTO group_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
"INSERT INTO group_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
(displayName, fullName, image, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -1238,9 +1249,9 @@ getUserGroupDetails st User {userId, userContactId} =
<$> DB.query
db
[sql|
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
m.group_member_id, g.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name
m.invited_by, m.local_display_name, m.contact_id, mp.display_name, mp.full_name, mp.image
FROM groups g
JOIN group_profiles gp USING (group_profile_id)
JOIN group_members m USING (group_id)
@@ -1255,12 +1266,12 @@ getGroupInfoByName st user gName =
gId <- ExceptT $ getGroupIdByName_ db user gName
ExceptT $ getGroupInfo_ db user gId
type GroupInfoRow = (Int64, GroupName, GroupName, Text, UTCTime) :. GroupMemberRow
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe ProfileImage, UTCTime) :. GroupMemberRow
toGroupInfo :: Int64 -> GroupInfoRow -> GroupInfo
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, createdAt) :. userMemberRow) =
toGroupInfo userContactId ((groupId, localDisplayName, displayName, fullName, image, createdAt) :. userMemberRow) =
let membership = toGroupMember userContactId userMemberRow
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName}, membership, createdAt}
in GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, fullName, image}, membership, createdAt}
getGroupMembers :: MonadUnliftIO m => SQLiteStore -> User -> GroupInfo -> m [GroupMember]
getGroupMembers st user gInfo = liftIO . withTransaction st $ \db -> getGroupMembers_ db user gInfo
@@ -1273,7 +1284,7 @@ getGroupMembers_ db User {userId, userContactId} GroupInfo {groupId} = do
[sql|
SELECT
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1313,20 +1324,20 @@ getGroupInvitation st user localDisplayName =
findFromContact (IBContact contactId) = find ((== Just contactId) . memberContactId)
findFromContact _ = const Nothing
type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text)
type GroupMemberRow = (Int64, Int64, MemberId, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, Maybe Int64, ContactName, Maybe Int64, ContactName, Text, Maybe ProfileImage)
type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text)
type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe Int64, Maybe ContactName, Maybe Int64, Maybe ContactName, Maybe Text, Maybe ProfileImage)
toGroupMember :: Int64 -> GroupMemberRow -> GroupMember
toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName) =
let memberProfile = Profile {displayName, fullName}
toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image) =
let memberProfile = Profile {displayName, fullName, image}
invitedBy = toInvitedBy userContactId invitedById
activeConn = Nothing
in GroupMember {..}
toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember
toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName) =
Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName)
toMaybeGroupMember userContactId (Just groupMemberId, Just groupId, Just memberId, Just memberRole, Just memberCategory, Just memberStatus, invitedById, Just localDisplayName, memberContactId, Just displayName, Just fullName, image) =
Just $ toGroupMember userContactId (groupMemberId, groupId, memberId, memberRole, memberCategory, memberStatus, invitedById, localDisplayName, memberContactId, displayName, fullName, image)
toMaybeGroupMember _ _ = Nothing
createContactMember :: StoreMonad m => SQLiteStore -> TVar ChaChaDRG -> User -> Int64 -> Contact -> GroupMemberRole -> ConnId -> ConnReqInvitation -> m GroupMember
@@ -1369,14 +1380,14 @@ updateGroupMemberStatus st userId GroupMember {groupMemberId} memStatus =
-- | add new member with profile
createNewGroupMember :: StoreMonad m => SQLiteStore -> User -> GroupInfo -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> m GroupMember
createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName}) memCategory memStatus =
createNewGroupMember st user@User {userId} gInfo memInfo@(MemberInfo _ _ Profile {displayName, fullName, image}) memCategory memStatus =
liftIOEither . withTransaction st $ \db ->
withLocalDisplayName db userId displayName $ \localDisplayName -> do
currentTs <- getCurrentTime
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, created_at, updated_at) VALUES (?,?,?,?)"
(displayName, fullName, currentTs, currentTs)
"INSERT INTO contact_profiles (display_name, full_name, image, created_at, updated_at) VALUES (?,?,?,?,?)"
(displayName, fullName, image, currentTs, currentTs)
memProfileId <- insertedRowId db
let newMember =
NewGroupMember
@@ -1635,15 +1646,15 @@ getViaGroupMember st User {userId, userContactId} Contact {contactId} =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupInfo {membership}
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name,
pu.display_name, pu.full_name, pu.image,
-- via GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category, m.member_status,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name,
m.invited_by, m.local_display_name, m.contact_id, p.display_name, p.full_name, p.image,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM group_members m
@@ -1677,7 +1688,7 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} =
db
[sql|
SELECT
ct.contact_id, ct.local_display_name, p.display_name, p.full_name, ct.via_group, ct.created_at,
ct.contact_id, ct.local_display_name, p.display_name, p.full_name, p.image, ct.via_group, ct.created_at,
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact,
c.conn_status, c.conn_type, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
FROM contacts ct
@@ -1693,9 +1704,9 @@ getViaGroupContact st User {userId} GroupMember {groupMemberId} =
|]
(userId, groupMemberId)
where
toContact' :: [(Int64, ContactName, Text, Text, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact
toContact' [(contactId, localDisplayName, displayName, fullName, viaGroup, createdAt) :. connRow] =
let profile = Profile {displayName, fullName}
toContact' :: [(Int64, ContactName, Text, Text, Maybe ProfileImage, Maybe Int64, UTCTime) :. ConnectionRow] -> Maybe Contact
toContact' [(contactId, localDisplayName, displayName, fullName, image, viaGroup, createdAt) :. connRow] =
let profile = Profile {displayName, fullName, image}
activeConn = toConnection connRow
in Just Contact {contactId, localDisplayName, profile, activeConn, viaGroup, createdAt}
toContact' _ = Nothing
@@ -2208,7 +2219,7 @@ getDirectChatPreviews_ db User {userId} = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at,
@@ -2265,11 +2276,11 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
pu.display_name, pu.full_name,
pu.display_name, pu.full_name, pu.image,
-- ChatStats
COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.MinUnread, 0),
-- ChatItem
@@ -2277,7 +2288,7 @@ getGroupChatPreviews_ db User {userId, userContactId} = do
-- Maybe GroupMember - sender
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
p.display_name, p.full_name
p.display_name, p.full_name, p.image
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
@@ -2318,7 +2329,7 @@ getContactRequestChatPreviews_ db User {userId} =
[sql|
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id, cr.user_contact_link_id,
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, cr.created_at, cr.xcontact_id
c.agent_conn_id, cr.contact_profile_id, p.display_name, p.full_name, p.image, cr.created_at, cr.xcontact_id
FROM contact_requests cr
JOIN connections c USING (user_contact_link_id)
JOIN contact_profiles p USING (contact_profile_id)
@@ -2453,7 +2464,7 @@ getContact_ db userId contactId =
[sql|
SELECT
-- Contact
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, ct.created_at,
ct.contact_id, ct.local_display_name, ct.via_group, cp.display_name, cp.full_name, cp.image, ct.created_at,
-- Connection
c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.conn_status, c.conn_type,
c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at
@@ -2504,12 +2515,12 @@ getGroupChatLast_ db user@User {userId, userContactId} groupId count = do
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
p.display_name, p.full_name
p.display_name, p.full_name, p.image
FROM chat_items ci
LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
WHERE ci.user_id = ? AND ci.group_id = ?
ORDER BY ci.chat_item_id DESC
ORDER BY ci.item_ts DESC, ci.chat_item_id DESC
LIMIT ?
|]
(userId, groupId, count)
@@ -2534,12 +2545,12 @@ getGroupChatAfter_ db user@User {userId, userContactId} groupId afterChatItemId
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
p.display_name, p.full_name
p.display_name, p.full_name, p.image
FROM chat_items ci
LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
WHERE ci.user_id = ? AND ci.group_id = ? AND ci.chat_item_id > ?
ORDER BY ci.chat_item_id ASC
ORDER BY ci.item_ts ASC, ci.chat_item_id ASC
LIMIT ?
|]
(userId, groupId, afterChatItemId, count)
@@ -2564,12 +2575,12 @@ getGroupChatBefore_ db user@User {userId, userContactId} groupId beforeChatItemI
-- GroupMember
m.group_member_id, m.group_id, m.member_id, m.member_role, m.member_category,
m.member_status, m.invited_by, m.local_display_name, m.contact_id,
p.display_name, p.full_name
p.display_name, p.full_name, p.image
FROM chat_items ci
LEFT JOIN group_members m ON m.group_member_id = ci.group_member_id
LEFT JOIN contact_profiles p ON p.contact_profile_id = m.contact_profile_id
WHERE ci.user_id = ? AND ci.group_id = ? AND ci.chat_item_id < ?
ORDER BY ci.chat_item_id DESC
ORDER BY ci.item_ts DESC, ci.chat_item_id DESC
LIMIT ?
|]
(userId, groupId, beforeChatItemId, count)
@@ -2604,11 +2615,11 @@ getGroupInfo_ db User {userId, userContactId} groupId =
[sql|
SELECT
-- GroupInfo
g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.created_at,
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.image, g.created_at,
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.member_id, mu.member_role, mu.member_category,
mu.member_status, mu.invited_by, mu.local_display_name, mu.contact_id,
pu.display_name, pu.full_name
pu.display_name, pu.full_name, pu.image
FROM groups g
JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id
JOIN group_members mu ON mu.group_id = g.group_id
@@ -2748,6 +2759,38 @@ toGroupChatItemList tz userContactId ((Just itemId, Just itemTs, Just itemConten
either (const []) (: []) $ toGroupChatItem tz userContactId ((itemId, itemTs, itemContent, itemText, itemStatus, createdAt) :. memberRow_)
toGroupChatItemList _ _ _ = []
getSMPServers :: MonadUnliftIO m => SQLiteStore -> User -> m [SMPServer]
getSMPServers st User {userId} =
liftIO . withTransaction st $ \db ->
map toSmpServer
<$> DB.query
db
[sql|
SELECT host, port, key_hash
FROM smp_servers
WHERE user_id = ?;
|]
(Only userId)
where
toSmpServer :: (String, String, C.KeyHash) -> SMPServer
toSmpServer (host, port, keyHash) = SMPServer host port keyHash
overwriteSMPServers :: StoreMonad m => SQLiteStore -> User -> [SMPServer] -> m ()
overwriteSMPServers st User {userId} smpServers = do
liftIOEither . checkConstraint SEUniqueID . withTransaction st $ \db -> do
currentTs <- getCurrentTime
DB.execute db "DELETE FROM smp_servers WHERE user_id = ?" (Only userId)
forM_ smpServers $ \SMPServer {host, port, keyHash} ->
DB.execute
db
[sql|
INSERT INTO smp_servers
(host, port, key_hash, user_id, created_at, updated_at)
VALUES (?,?,?,?,?,?)
|]
(host, port, keyHash, userId, currentTs, currentTs)
pure $ Right ()
-- | Saves unique local display name based on passed displayName, suffixed with _N if required.
-- This function should be called inside transaction.
withLocalDisplayName :: forall a. DB.Connection -> UserId -> Text -> (Text -> IO a) -> IO (Either StoreError a)
+24 -4
View File
@@ -170,19 +170,39 @@ groupName' GroupInfo {localDisplayName = g} = g
data Profile = Profile
{ displayName :: ContactName,
fullName :: Text
fullName :: Text,
image :: Maybe ProfileImage
}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON Profile where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON Profile where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data GroupProfile = GroupProfile
{ displayName :: GroupName,
fullName :: Text
fullName :: Text,
image :: Maybe ProfileImage
}
deriving (Eq, Show, Generic, FromJSON)
instance ToJSON GroupProfile where toEncoding = J.genericToEncoding J.defaultOptions
instance ToJSON GroupProfile where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
newtype ProfileImage = ProfileImage Text
deriving (Eq, Show)
instance FromJSON ProfileImage where
parseJSON = fmap ProfileImage . J.parseJSON
instance ToJSON ProfileImage where
toJSON (ProfileImage t) = J.toJSON t
toEncoding (ProfileImage t) = J.toEncoding t
instance ToField ProfileImage where toField (ProfileImage t) = toField t
instance FromField ProfileImage where fromField = fmap ProfileImage . fromField
data GroupInvitation = GroupInvitation
{ fromMember :: MemberIdRole,
+29 -7
View File
@@ -8,10 +8,11 @@
module Simplex.Chat.View where
import qualified Data.Aeson as J
import qualified Data.ByteString.Char8 as B
import Data.Function (on)
import Data.Int (Int64)
import Data.List (groupBy, intersperse, partition, sortOn)
import Data.Maybe (isJust)
import Data.List (groupBy, intercalate, intersperse, partition, sortOn)
import Data.Maybe (isJust, isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (DiffTime)
@@ -39,8 +40,10 @@ responseToView :: Bool -> ChatResponse -> [StyledString]
responseToView testView = \case
CRActiveUser User {profile} -> viewUserProfile profile
CRChatStarted -> ["chat started"]
CRChatRunning -> []
CRApiChats chats -> if testView then testViewChats chats else [plain . bshow $ J.encode chats]
CRApiChat chat -> if testView then testViewChat chat else [plain . bshow $ J.encode chat]
CRUserSMPServers smpServers -> viewSMPServers smpServers testView
CRNewChatItem (AChatItem _ _ chat item) -> viewChatItem chat item
CRChatItemUpdated _ -> []
CRMsgIntegrityError mErr -> viewMsgIntegrityError mErr
@@ -103,9 +106,9 @@ responseToView testView = \case
CRContactSubscribed c -> [ttyContact' c <> ": connected to server"]
CRContactSubError c e -> [ttyContact' c <> ": contact error " <> sShow e]
CRContactSubSummary summary ->
(if null connected then [] else [sShow (length connected) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)"]) <> viewErrorsSummary errors " contact errors"
(if null subscribed then [] else [sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)"]) <> viewErrorsSummary errors " contact errors"
where
(errors, connected) = partition (isJust . contactError) summary
(errors, subscribed) = partition (isJust . contactError) summary
CRGroupInvitation GroupInfo {localDisplayName = ldn, groupProfile = GroupProfile {fullName}} ->
[groupInvitation ldn fullName]
CRReceivedGroupInvitation g c role -> viewReceivedGroupInvitation g c role
@@ -122,6 +125,7 @@ responseToView testView = \case
CRMemberSubError g c e -> [ttyGroup' g <> " member " <> ttyContact c <> " error: " <> sShow e]
CRMemberSubErrors summary -> viewErrorsSummary summary " group member errors"
CRGroupSubscribed g -> [ttyFullGroup g <> ": connected to server(s)"]
CRPendingSubSummary _ -> []
CRSndFileSubError SndFileTransfer {fileId, fileName} e ->
["sent file " <> sShow fileId <> " (" <> plain fileName <> ") error: " <> sShow e]
CRRcvFileSubError RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName}} e ->
@@ -314,9 +318,27 @@ viewUserProfile Profile {displayName, fullName} =
"(the updated profile will be sent to all your contacts)"
]
viewSMPServers :: [SMPServer] -> Bool -> [StyledString]
viewSMPServers smpServers testView =
if testView
then [customSMPServers]
else
[ customSMPServers,
"",
"use " <> highlight' "/smp_servers <srv1[,srv2,...]>" <> " to switch to custom SMP servers",
"use " <> highlight' "/smp_servers default" <> " to remove custom SMP servers and use default",
"(chat option " <> highlight' "-s" <> " (" <> highlight' "--server" <> ") has precedence over saved SMP servers for chat session)"
]
where
customSMPServers =
if null smpServers
then "no custom SMP servers saved"
else plain $ intercalate ", " (map (B.unpack . strEncode) smpServers)
viewUserProfileUpdated :: Profile -> Profile -> [StyledString]
viewUserProfileUpdated Profile {displayName = n, fullName} Profile {displayName = n', fullName = fullName'}
| n == n' && fullName == fullName' = []
viewUserProfileUpdated Profile {displayName = n, fullName, image} Profile {displayName = n', fullName = fullName', image = image'}
| n == n' && fullName == fullName' && image == image' = []
| n == n' && fullName == fullName' = [if isNothing image' then "profile image removed" else "profile image updated"]
| 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
@@ -369,7 +391,7 @@ ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M"
ttyMsgContent :: MsgContent -> [StyledString]
ttyMsgContent = \case
MCText t -> msgPlain t
MCUnknown -> ["unknown message type"]
MCUnknown _ t -> msgPlain t
ttySentFile :: StyledString -> FileTransferId -> FilePath -> [StyledString]
ttySentFile to fId fPath = ["/f " <> to <> ttyFilePath fPath, "use " <> highlight ("/fc " <> show fId) <> " to cancel sending"]
+1 -1
View File
@@ -48,7 +48,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: d1e6147adfbd46f5e3e996cc6365d8f3f0f7669c
commit: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc
# - terminal-0.2.0.0@sha256:de6770ecaae3197c66ac1f0db5a80cf5a5b1d3b64a66a05b50f442de5ad39570,2977
- github: simplex-chat/aeson
commit: 3eb66f9a68f103b5f1489382aad89f5712a64db7
+6
View File
@@ -132,6 +132,12 @@ testChatN ps test = withTmpFiles $ do
getTermLine :: TestCC -> IO String
getTermLine = atomically . readTQueue . termQ
-- Use below to echo virtual terminal
-- getTermLine cc = do
-- s <- atomically . readTQueue $ termQ cc
-- putStrLn s
-- pure s
testChat2 :: Profile -> Profile -> (TestCC -> TestCC -> IO ()) -> IO ()
testChat2 p1 p2 test = testChatN [p1, p2] test_
where
+40 -12
View File
@@ -6,6 +6,7 @@
module ChatTests where
import ChatClient
import Control.Concurrent (threadDelay)
import Control.Concurrent.Async (concurrently_)
import Control.Concurrent.STM
import qualified Data.ByteString as B
@@ -13,27 +14,29 @@ import Data.Char (isDigit)
import Data.Maybe (fromJust)
import qualified Data.Text as T
import Simplex.Chat.Controller (ChatController (..))
import Simplex.Chat.Types (Profile (..), User (..))
import Simplex.Chat.Types (Profile (..), ProfileImage (..), User (..))
import Simplex.Chat.Util (unlessM)
import System.Directory (doesFileExist)
import Test.Hspec
aliceProfile :: Profile
aliceProfile = Profile {displayName = "alice", fullName = "Alice"}
aliceProfile = Profile {displayName = "alice", fullName = "Alice", image = Nothing}
bobProfile :: Profile
bobProfile = Profile {displayName = "bob", fullName = "Bob"}
bobProfile = Profile {displayName = "bob", fullName = "Bob", image = Just (ProfileImage "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAKHGlDQ1BJQ0MgUHJvZmlsZQAASImFVgdUVNcWve9Nb7QZeu9NehtAem/Sq6gMQ28OQxWxgAQjEFFEREARNFQFg1KjiIhiIQgoYA9IEFBisCAq6OQNJNH4//r/zDpz9ttzz7n73ffWmg0A6QCDxYqD+QCIT0hmezlYywQEBsngngEYCAIy0AC6DGYSy8rDwxUg8Xf9d7wbAxC33tHgzvrP3/9nCISFJzEBgIIRTGey2MkILkawT1oyi4tnEUxjI6IQvMLFkauYqxjQQtewwuoaHy8bBNMBwJMZDHYkAERbhJdJZUYic4hhCNZOCItOQDB3vjkzioFwxLsIXhcRl5IOAImrRzs+fivCk7QRrIL0shAcwNUW+tX8yH/tFfrPXgxG5D84Pi6F+dc9ck+HHJ7g641UMSQlQATQBHEgBaQDGcACbLAVYaIRJhx5Dv+9j77aZ4OsZIFtSEc0iARRIBnpt/9qlvfqpGSQBhjImnCEcUU+NtxnujZy4fbqVEiU/wuXdQyA9S0cDqfzC+e2F4DzyLkSB79wyi0A8KoBcL2GmcJOXePQ3C8MIAJeQAOiQArIAxXuWwMMgSmwBHbAGbgDHxAINgMmojceUZUGMkEWyAX54AA4DMpAJTgJ6sAZ0ALawQVwGVwDt8AQGAUPwQSYBi/AAngHliEIwkEUiAqJQtKQIqQO6UJ0yByyg1whLygQCoEioQQoBcqE9kD5UBFUBlVB9dBPUCd0GboBDUP3oUloDnoNfYRRMBmmwZKwEqwF02Er2AX2gTfBkXAinAHnwPvhUrgaPg23wZfhW/AoPAG/gBdRAEVCCaFkURooOsoG5Y4KQkWg2KidqDxUCaoa1YTqQvWj7qAmUPOoD2gsmoqWQWugTdGOaF80E52I3okuQJeh69Bt6D70HfQkegH9GUPBSGDUMSYYJ0wAJhKThsnFlGBqMK2Yq5hRzDTmHRaLFcIqY42wjthAbAx2O7YAewzbjO3BDmOnsIs4HE4Up44zw7njGLhkXC7uKO407hJuBDeNe48n4aXxunh7fBA+AZ+NL8E34LvxI/gZ/DKBj6BIMCG4E8II2wiFhFOELsJtwjRhmchPVCaaEX2IMcQsYimxiXiV+Ij4hkQiyZGMSZ6kaNJuUinpLOk6aZL0gSxAViPbkIPJKeT95FpyD/k++Q2FQlGiWFKCKMmU/ZR6yhXKE8p7HiqPJo8TTxjPLp5ynjaeEZ6XvAReRV4r3s28GbwlvOd4b/PO8xH4lPhs+Bh8O/nK+Tr5xvkW+an8Ovzu/PH8BfwN/Df4ZwVwAkoCdgJhAjkCJwWuCExRUVR5qg2VSd1DPUW9Sp2mYWnKNCdaDC2fdoY2SFsQFBDUF/QTTBcsF7woOCGEElISchKKEyoUahEaE/ooLClsJRwuvE+4SXhEeElEXMRSJFwkT6RZZFTko6iMqJ1orOhB0XbRx2JoMTUxT7E0seNiV8XmxWnipuJM8TzxFvEHErCEmoSXxHaJkxIDEouSUpIOkizJo5JXJOelhKQspWKkiqW6peakqdLm0tHSxdKXpJ/LCMpYycTJlMr0ySzISsg6yqbIVskOyi7LKcv5ymXLNcs9lifK0+Uj5Ivle+UXFKQV3BQyFRoVHigSFOmKUYpHFPsVl5SUlfyV9iq1K80qiyg7KWcoNyo/UqGoWKgkqlSr3FXFqtJVY1WPqQ6pwWoGalFq5Wq31WF1Q/Vo9WPqw+sw64zXJayrXjeuQdaw0kjVaNSY1BTSdNXM1mzXfKmloBWkdVCrX+uztoF2nPYp7Yc6AjrOOtk6XTqvddV0mbrlunf1KHr2erv0OvRe6avrh+sf179nQDVwM9hr0GvwydDIkG3YZDhnpGAUYlRhNE6n0T3oBfTrxhhja+NdxheMP5gYmiSbtJj8YaphGmvaYDq7Xnl9+PpT66fM5MwYZlVmE+Yy5iHmJ8wnLGQtGBbVFk8t5S3DLGssZ6xUrWKsTlu9tNa2Zlu3Wi/ZmNjssOmxRdk62ObZDtoJ2Pnaldk9sZezj7RvtF9wMHDY7tDjiHF0cTzoOO4k6cR0qndacDZy3uHc50J28XYpc3nqqubKdu1yg92c3Q65PdqguCFhQ7s7cHdyP+T+2EPZI9HjZ0+sp4dnueczLx2vTK9+b6r3Fu8G73c+1j6FPg99VXxTfHv9eP2C/er9lvxt/Yv8JwK0AnYE3AoUC4wO7AjCBfkF1QQtbrTbeHjjdLBBcG7w2CblTembbmwW2xy3+eIW3i2MLedCMCH+IQ0hKwx3RjVjMdQptCJ0gWnDPMJ8EWYZVhw2F24WXhQ+E2EWURQxG2kWeShyLsoiqiRqPtomuiz6VYxjTGXMUqx7bG0sJ84/rjkeHx8S35kgkBCb0LdVamv61mGWOiuXNZFokng4cYHtwq5JgpI2JXUk05A/0oEUlZTvUiZTzVPLU9+n+aWdS+dPT0gf2Ka2bd+2mQz7jB+3o7czt/dmymZmZU7usNpRtRPaGbqzd5f8rpxd07sddtdlEbNis37J1s4uyn67x39PV45kzu6cqe8cvmvM5cll547vNd1b+T36++jvB/fp7Tu673NeWN7NfO38kvyVAmbBzR90fij9gbM/Yv9goWHh8QPYAwkHxg5aHKwr4i/KKJo65HaorVimOK/47eEth2+U6JdUHiEeSTkyUepa2nFU4eiBoytlUWWj5dblzRUSFfsqlo6FHRs5bnm8qVKyMr/y44noE/eqHKraqpWqS05iT6aefHbK71T/j/Qf62vEavJrPtUm1E7UedX11RvV1zdINBQ2wo0pjXOng08PnbE909Gk0VTVLNScfxacTTn7/KeQn8ZaXFp6z9HPNZ1XPF/RSm3Na4PatrUttEe1T3QEdgx3Onf2dpl2tf6s+XPtBdkL5RcFLxZ2E7tzujmXMi4t9rB65i9HXp7q3dL78ErAlbt9nn2DV12uXr9mf+1Kv1X/petm1y/cMLnReZN+s/2W4a22AYOB1l8MfmkdNBxsu210u2PIeKhreP1w94jFyOU7tneu3XW6e2t0w+jwmO/YvfHg8Yl7Yfdm78fdf/Ug9cHyw92PMI/yHvM9Lnki8aT6V9VfmycMJy5O2k4OPPV++nCKOfXit6TfVqZznlGelcxIz9TP6s5emLOfG3q+8fn0C9aL5fnc3/l/r3ip8vL8H5Z/DCwELEy/Yr/ivC54I/qm9q3+295Fj8Un7+LfLS/lvRd9X/eB/qH/o//HmeW0FdxK6SfVT12fXT4/4sRzOCwGm7FqBVBIwhERALyuBYASCAB1CPEPG9f8119+BvrK2fyNwVndL5jhvubRVsMQgCakeCFp04OsQ1LJEgAe5NodqT6WANbT+yf/iqQIPd21PXgaAcDJcjivtwJAQHLFgcNZ9uBwPlUgYhHf1z37f7V9g9e8ITewiP88wfWIYET6HPg21nzjV2fybQVcxfrg2/onng/F50lD/ccAAAA4ZVhJZk1NACoAAAAIAAGHaQAEAAAAAQAAABoAAAAAAAKgAgAEAAAAAQAAABigAwAEAAAAAQAAABgAAAAAwf1XlwAAAaNJREFUSA3FlT1LA0EQQBN/gYUYRTksJZVgEbCR/D+7QMr8ABtttBBCsLGzsLG2sxaxED/ie4d77u0dyaE5HHjczn7MzO7M7nU6/yXz+bwLhzCCjTQO+rZhDH3opuNLdRYN4RHe4RIKJ7R34Ro+4AEGSw2mE1iUwT18gpI74WvkGlccu4XNdH0jnYU7cAUacidn37qR23cOxc4aGU0nYUAn7iSWEHkz46w0ocdQu1X6B/AMQZ5o7KfBqNOfwRH8JB7FajGhnmcpKvQe3MEbvILiDm5gPXaCHnZr4vvFGMoEKudKn8YvQIOOe+YzCPop7dwJ3zRfJ7GDuso4YJGRa0yZgg4tUaNXdGrbuZWKKxzYYEJc2xp9AUUjGt8KC2jvgYadF8+10vJyDnNLXwbdiWUZi0fUK01Eoc+AZhCLZVzK4Vq6sDUdz+0dEcbbTTIOJmAyTVhx/WmvrExbv2jtPhWLKodjCtefZiEeZeVZWWSndgwj6fVf3XON8Qwq15++uoqrfYVrow6dGBpCq79ME291jaB0/Q2CPncyht/99MNO/vr9AqW/CGi8sJqbAAAAAElFTkSuQmCC")}
cathProfile :: Profile
cathProfile = Profile {displayName = "cath", fullName = "Catherine"}
cathProfile = Profile {displayName = "cath", fullName = "Catherine", image = Nothing}
danProfile :: Profile
danProfile = Profile {displayName = "dan", fullName = "Daniel"}
danProfile = Profile {displayName = "dan", fullName = "Daniel", image = Nothing}
chatTests :: Spec
chatTests = do
describe "direct messages" $
it "add contact and send/receive message" testAddContact
describe "SMP servers" $
it "get and set SMP servers" testGetSetSMPServers
describe "chat groups" $ do
it "add contacts, create group and send/receive messages" testGroup
it "create and join group with 4 members" testGroup2
@@ -42,8 +45,9 @@ chatTests = do
it "re-add member in status invited" testGroupReAddInvited
it "remove contact from group and add again" testGroupRemoveAdd
it "list groups containing group invitations" testGroupList
describe "user profiles" $
describe "user profiles" $ do
it "update user profiles and notify contacts" testUpdateProfile
it "update user profile with image" testUpdateProfileImage
describe "sending and receiving files" $ do
it "send and receive file" testFileTransfer
it "send and receive a small file" testSmallFileTransfer
@@ -51,12 +55,12 @@ chatTests = do
it "recipient cancelled file transfer" testFileRcvCancel
it "send and receive file to group" testGroupFileTransfer
describe "user contact link" $ do
it "should create and connect via contact link" testUserContactLink
it "should auto accept contact requests" testUserContactLinkAutoAccept
it "should deduplicate contact requests" testDeduplicateContactRequests
it "should deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange
it "should reject contact and delete contact link" testRejectContactAndDeleteUserContact
it "should delete connection requests when contact link deleted" testDeleteConnectionRequests
it "create and connect via contact link" testUserContactLink
it "auto accept contact requests" testUserContactLinkAutoAccept
it "deduplicate contact requests" testDeduplicateContactRequests
it "deduplicate contact requests with profile change" testDeduplicateContactRequestsProfileChange
it "reject contact and delete contact link" testRejectContactAndDeleteUserContact
it "delete connection requests when contact link deleted" testDeleteConnectionRequests
testAddContact :: IO ()
testAddContact =
@@ -116,6 +120,18 @@ testAddContact =
alice #$$> ("/_get chats", [("@bob", "hi")])
bob #$$> ("/_get chats", [("@alice_1", "hi"), ("@alice", "hi")])
testGetSetSMPServers :: IO ()
testGetSetSMPServers =
testChat2 aliceProfile bobProfile $
\alice _ -> do
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
alice #$> ("/smp_servers smp://1234-w==@smp1.example.im", id, "ok")
alice #$> ("/smp_servers", id, "smp://1234-w==@smp1.example.im")
alice #$> ("/smp_servers smp://2345-w==@smp2.example.im,smp://3456-w==@smp3.example.im:5224", id, "ok")
alice #$> ("/smp_servers", id, "smp://2345-w==@smp2.example.im, smp://3456-w==@smp3.example.im:5224")
alice #$> ("/smp_servers default", id, "ok")
alice #$> ("/smp_servers", id, "no custom SMP servers saved")
testGroup :: IO ()
testGroup =
testChat3 aliceProfile bobProfile cathProfile $
@@ -157,10 +173,12 @@ testGroup =
concurrently_
(bob <# "#team alice> hello")
(cath <# "#team alice> hello")
threadDelay 1000000 -- server assigns timestamps with one second precision
bob #> "#team hi there"
concurrently_
(alice <# "#team bob> hi there")
(cath <# "#team bob> hi there")
threadDelay 1000000
cath #> "#team hey team"
concurrently_
(alice <# "#team cath> hey team")
@@ -567,6 +585,16 @@ testUpdateProfile =
bob <## "use @cat <message> to send messages"
]
testUpdateProfileImage :: IO ()
testUpdateProfileImage =
testChat2 aliceProfile bobProfile $
\alice bob -> do
connectUsers alice bob
-- Note we currently don't support removing profile image.
alice ##> "/profile_image data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="
alice <## "profile image updated"
(bob </)
testFileTransfer :: IO ()
testFileTransfer =
testChat2 aliceProfile bobProfile $
+12 -12
View File
@@ -70,10 +70,10 @@ s #==# msg = do
s ==# msg
testProfile :: Profile
testProfile = Profile {displayName = "alice", fullName = "Alice"}
testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ProfileImage "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=")}
testGroupProfile :: GroupProfile
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team"}
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", image = Nothing}
decodeChatMessageTest :: Spec
decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
@@ -82,19 +82,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
"{\"event\":\"x.file\",\"params\":{\"file\":{\"fileConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"fileSize\":12345,\"fileName\":\"photo.jpg\"}}}"
#==# XFile FileInvitation {fileName = "photo.jpg", fileSize = 12345, fileConnReq = testConnReq}
it "x.file.acpt" $ "{\"event\":\"x.file.acpt\",\"params\":{\"fileName\":\"photo.jpg\"}}" #==# XFileAcpt "photo.jpg"
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}" #==# XInfo testProfile
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = ""}
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}" #==# XInfo testProfile
it "x.info" $ "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\"}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing}
it "x.contact with xContactId" $
"{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
"{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XContact testProfile (Just $ XContactId "\1\2\3\4")
it "x.contact without XContactId" $
"{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
"{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XContact testProfile Nothing
it "x.contact with content null" $
"{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
"{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing
it "x.contact with content (ignored)" $
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
"{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
==# XContact testProfile Nothing
it "x.grp.inv" $
"{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\"},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}"
@@ -102,19 +102,19 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4")
it "x.grp.acpt" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4")
it "x.grp.mem.new" $
"{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}}"
"{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
it "x.grp.mem.intro" $
"{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}}"
"{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile}
it "x.grp.mem.inv" $
"{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}"
#==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.fwd" $
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}}"
"{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23MCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%3D&e2e=v%3D1%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}}"
#==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq}
it "x.grp.mem.info" $
"{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\"}}}"
"{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\"}}}"
#==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile
it "x.grp.mem.con" $ "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemCon (MemberId "\1\2\3\4")
it "x.grp.mem.con.all" $ "{\"event\":\"x.grp.mem.con.all\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpMemConAll (MemberId "\1\2\3\4")