Compare commits

...

79 Commits

Author SHA1 Message Date
Evgeny Poberezkin 490dc17571 Merge PR #450 - v1.3 release
merge v1.3 to stable
2022-03-19 09:17:35 +00:00
Evgeny Poberezkin b57a77c8f0 Merge branch 'stable' 2022-03-19 09:05:30 +00:00
Evgeny Poberezkin fe0e5e8b89 terminal: version 1.3.3 (#447)
* terminal: show version from .cabal file

* update welcome message

* terminal: helo on message quotes

* terminal: allow replies in groups without specifying a member

* core: update version to 1.3.3
2022-03-19 09:04:53 +00:00
Evgeny Poberezkin 3340bea150 core: api to remove profile image (#448) 2022-03-19 07:42:54 +00:00
Evgeny Poberezkin 0e73697ea4 mobile: show app version/build, update settings, update build number (16: android, 28: ios) (#445) 2022-03-18 09:23:01 +00:00
sh 4fcbec49c9 readme: add fdroid badge (#446) 2022-03-18 08:21:36 +00:00
Evgeny Poberezkin 01994d8c6a android: fix message entry size after sending emoji, build 15 2022-03-17 18:01:47 +00:00
Evgeny Poberezkin 31de7fd0ee mobile: update version/build 1.3 (ios: 27, android 14) 2022-03-17 10:34:31 +00:00
Evgeny Poberezkin 744c451927 mobile: message actions (reply, share, copy) (#431)
* ios: add context menu to messages

* ios: UI for replies with quotes

* fix: scrolling crashing in chat

* ios: UI for message replies with quotes

* android: UI for message replies

* android: messages with quotes

* android: update imports

* android: refactor ChatItemView

* remove comments
2022-03-17 09:42:59 +00:00
Evgeny Poberezkin 148474e1ba core: change quoted messages types/protocol (#443)
* core: change quoted messages types/protocol

* remove comments and unused field

* rename CIQuote type

* change type for quote direction to allow unknown group member, use QuotedMsg to save received chat item

* change queries of quoted items when the sending group member is known

* refactor

* fix: make ciQuote polymorphic
2022-03-16 13:20:47 +00:00
Evgeny Poberezkin d4765bcfec Merge branch 'stable' 2022-03-14 21:04:05 +00:00
Evgeny Poberezkin e4ea2035ff android: fix app crashing on opening chats, build 12 (#439) 2022-03-14 21:03:36 +00:00
Evgeny Poberezkin 3a28bacf14 Merge branch 'stable' 2022-03-14 21:01:54 +00:00
Evgeny Poberezkin 6ba7d208c8 terminal: version 1.3.2 (#442) 2022-03-14 20:58:53 +00:00
Mark Aleksander Hil 102fdf3b18 mobile: update copy, fix typo (#440)
* Updated copy and fixed typo

* Updated copy and fixed typo
2022-03-14 20:58:19 +00:00
Evgeny Poberezkin 1f539fc8be hide secrets in notifications, closes #416 (#424)
* terminal: hide secrets in notifications #416

* ios: hide secrets in notifications

* android: hide secrets in notifications
2022-03-13 20:13:47 +00:00
Evgeny Poberezkin 806f417e99 message replies and chat item references (#394)
* rfc for message replies and chat item references

* update replies rfc

* save received/sent shared message ids, migration and types for replies

* include reply/forward into MsgContent type

* add sharedMsgId to CIMeta

* save/get shared_msg_id to/from chat items table

* parameterize CIRef by chat type

* add CIRef to ChatItem when it is read from the db

* terminal command to send message replies

* include quoted content into chat items

* quoted message direction in direct chats (TODO test)

* test for replies with quotes to group messages - own and others

* split MsgContainer from MsgContent

* make quoting usable in the terminal

* add formattedText to quotes

* rename migration

* update JSON encoding for MsgContainer

* allow quoted replies to messages from clients not supporting it/not sending msg IDs

* update rfc

* fix group replies

* add APISendMessageQuote and use it for terminal commands

* change how quoted messages are shown in groups
2022-03-13 19:34:03 +00:00
IanRDavies 6c04184a9c core: filter contacts on connection status before broadcasting profile updates (#430)
* filter contacts on connection status before broadcasting profile updates

* catch and report errors when notifying contacts about profile updates

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-03-12 20:57:11 +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
104 changed files with 4700 additions and 1994 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
+18 -8
View File
@@ -1,22 +1,32 @@
<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/f_droid.svg" alt="F-Droid" height="41">](https://app.simplex.chat)
&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="APK" 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
+15 -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 16
versionName "1.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
@@ -40,6 +40,13 @@ android {
}
kotlinOptions {
jvmTarget = '1.8'
freeCompilerArgs += "-opt-in=kotlinx.coroutines.DelicateCoroutinesApi"
freeCompilerArgs += "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi"
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 +73,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 +81,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,118 @@ 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: String = """
// """.trimIndent()
//
// println(json.decodeFromString<APIResponse>(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,44 @@
package chat.simplex.app.model
import android.content.Context
import android.util.Log
import androidx.work.*
import chat.simplex.app.TAG
import kotlinx.datetime.Clock
import java.time.Duration
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)
@@ -240,9 +241,9 @@ data class Chat (
val statusString: String get() = if (this is Connected) "Server connected" else "Connecting server…"
val statusExplanation: String get() =
when {
this is Connected -> "You are connected to the server you use to receve messages from this contact."
this is Error -> "Trying to connect to the server you use to receve messages from this contact (error: $error)."
else -> "Trying to connect to the server you use to receve messages from this contact."
this is Connected -> "You are connected to the server used to receive messages from this contact."
this is Error -> "Trying to connect to the server used to receive messages from this contact (error: $error)."
else -> "Trying to connect to the server used to receive messages from this contact."
}
@Serializable @SerialName("unknown") class Unknown: NetworkStatus()
@@ -465,24 +466,31 @@ data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
val content: CIContent,
val formattedText: List<FormattedText>? = null
val formattedText: List<FormattedText>? = null,
val quotedItem: CIQuote? = null
) {
val id: Long get() = meta.itemId
val timestampText: String get() = meta.timestampText
val isRcvNew: Boolean get() = meta.itemStatus is CIStatus.RcvNew
val memberDisplayName: String? get() =
if (chatDir is CIDirection.GroupRcv) chatDir.groupMember.memberProfile.displayName
else null
companion object {
fun getSampleData(
id: Long = 1,
dir: CIDirection = CIDirection.DirectSnd(),
ts: Instant = Clock.System.now(),
text: String = "hello\nthere",
status: CIStatus = CIStatus.SndNew()
status: CIStatus = CIStatus.SndNew(),
quotedItem: CIQuote? = null
) =
ChatItem(
chatDir = dir,
meta = CIMeta.getSample(id, ts, text, status),
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text))
content = CIContent.SndMsgContent(msgContent = MsgContent.MCText(text)),
quotedItem = quotedItem
)
}
}
@@ -565,9 +573,13 @@ sealed class CIStatus {
class RcvRead: CIStatus()
}
interface ItemContent {
val text: String
}
@Serializable
sealed class CIContent {
abstract val text: String
sealed class CIContent: ItemContent {
abstract override val text: String
@Serializable @SerialName("sndMsgContent")
class SndMsgContent(val msgContent: MsgContent): CIContent() {
@@ -591,13 +603,81 @@ sealed class CIContent {
}
@Serializable
class CIQuote (
val chatDir: CIDirection? = null,
val itemId: Long? = null,
val sharedMsgId: String? = null,
val sentAt: Instant,
val content: MsgContent,
val formattedText: List<FormattedText>? = null
): ItemContent {
override val text: String get() = content.text
fun sender(user: User): String? = when (chatDir) {
is CIDirection.DirectSnd -> "you"
is CIDirection.DirectRcv -> null
is CIDirection.GroupSnd -> user.displayName
is CIDirection.GroupRcv -> chatDir.groupMember.memberProfile.displayName
null -> null
}
companion object {
fun getSample(itemId: Long?, sentAt: Instant, text: String, chatDir: CIDirection?): CIQuote =
CIQuote(chatDir = chatDir, itemId = itemId, sentAt = sentAt, content = MsgContent.MCText(text))
}
}
@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 +734,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,90 @@
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(hideSecrets(cItem))
.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 hideSecrets(cItem: ChatItem): String {
val md = cItem.formattedText
return if (md == null) {
cItem.content.text
} else {
var res = ""
for (ft in md) {
res += if (ft.format is Format.Secret) "..." else ft.text
}
res
}
}
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,45 @@ 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))
suspend fun apiSendMessage(type: ChatType, id: Long, quotedItemId: Long? = null, mc: MsgContent): AChatItem? {
val cmd = if (quotedItemId == null) CC.ApiSendMessage(type, id, mc)
else CC.ApiSendMessageQuote(type, id, quotedItemId, mc)
val r = sendCmd(cmd)
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 +165,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 +186,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 +206,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 +231,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 +299,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 +309,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 +345,9 @@ 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 ApiSendMessageQuote(val type: ChatType, val id: Long, val itemId: 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 +367,9 @@ 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 ApiSendMessageQuote -> "/_send_quote ${chatRef(type, id)} $itemId ${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 +390,9 @@ sealed class CC {
is ApiGetChats -> "apiGetChats"
is ApiGetChat -> "apiGetChat"
is ApiSendMessage -> "apiSendMessage"
is ApiSendMessageQuote -> "apiSendMessageQuote"
is GetUserSMPServers -> "getUserSMPServers"
is SetUserSMPServers -> "setUserSMPServers"
is AddContact -> "addContact"
is Connect -> "connect"
is ApiDeleteChat -> "apiDeleteChat"
@@ -360,6 +409,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 = ",")
}
}
@@ -376,6 +427,7 @@ class APIResponse(val resp: CR, val corr: String? = null) {
json.decodeFromString(str)
} catch(e: Exception) {
try {
Log.d(TAG, e.localizedMessage)
val data = json.parseToJsonElement(str).jsonObject
APIResponse(
resp = CR.Response(data["resp"]!!.jsonObject["type"]?.toString() ?: "invalid", json.encodeToString(data)),
@@ -394,8 +446,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 +484,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 +523,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,99 +1,108 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBack
import androidx.compose.material.icons.outlined.*
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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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.HighOrLowlight
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 }
val user = chatModel.currentUser.value
if (chat == null || user == null) {
chatModel.chatId.value = null
} else {
val quotedItem = remember { mutableStateOf<ChatItem?>(null) }
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(user, chat, chatModel.chatItems, quotedItem,
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,
quotedItemId = quotedItem.value?.meta?.itemId,
mc = MsgContent.MCText(msg)
)
quotedItem.value = null
// hide "in progress"
if (newItem != null) chatModel.addChatItem(cInfo, newItem.chatItem)
}
}
)
}
}
@ExperimentalTextApi
@DelicateCoroutinesApi
@ExperimentalAnimatedInsets
@Composable
fun ChatLayout(
chat: Chat, chatItems: List<ChatItem>,
user: User,
chat: Chat,
chatItems: List<ChatItem>,
quotedItem: MutableState<ChatItem?>,
back: () -> Unit,
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 = { ComposeView(quotedItem, sendMessage) },
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Box(Modifier.padding(contentPadding)) {
ChatItemsList(user, chatItems, quotedItem)
}
}
}
}
@@ -101,7 +110,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,29 +148,42 @@ 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>) {
fun ChatItemsList(user: User, chatItems: List<ChatItem>, quotedItem: MutableState<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
val cxt = LocalContext.current
LazyColumn(state = listState) {
items(chatItems) { cItem ->
ChatItemView(cItem, uriHandler)
ChatItemView(user, cItem, quotedItem, cxt, uriHandler)
}
val len = chatItems.count()
if (len > 1) {
if (len > 1 && (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,
@@ -186,12 +211,14 @@ fun PreviewChatLayout() {
)
)
ChatLayout(
user = User.sampleData,
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = chatItems,
chatStats = Chat.ChatStats()
),
chatItems = chatItems,
quotedItem = remember { mutableStateOf(null) },
back = {},
info = {},
sendMessage = {}
@@ -0,0 +1,14 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import chat.simplex.app.model.ChatItem
@Composable
fun ComposeView(quotedItem: MutableState<ChatItem?>, sendMessage: (String) -> Unit) {
Column {
QuotedItemView(quotedItem)
SendMsgView(sendMessage)
}
}
@@ -0,0 +1,77 @@
package chat.simplex.app.views.chat
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Close
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.*
import kotlinx.datetime.Clock
@Composable
fun QuotedItemView(quotedItem: MutableState<ChatItem?>) {
val qi = quotedItem.value
if (qi != null) {
val sent = qi.chatDir.sent
Row(
Modifier.padding(top = 8.dp)
.background(if (sent) SentColorLight else ReceivedColorLight),
verticalAlignment = Alignment.CenterVertically
) {
Box(
Modifier.padding(start = 16.dp)
.padding(vertical = 12.dp)
.fillMaxWidth()
.weight(1F)
) {
QuoteText(qi)
}
IconButton(onClick = { quotedItem.value = null }) {
Icon(
Icons.Outlined.Close,
"Remove quote",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
}
}
@Composable
private fun QuoteText(qi: ChatItem) {
val member = qi.memberDisplayName
if (member == null) {
Text(qi.content.text, maxLines = 3)
} else {
val annotatedText = buildAnnotatedString {
withStyle(boldFont) { append(member) }
append(": ${qi.content.text}")
}
Text(annotatedText, maxLines = 3)
}
}
@Preview
@Composable
fun PreviewTextItemViewEmoji() {
SimpleXTheme {
QuotedItemView(
quotedItem = remember {
mutableStateOf(ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
))
}
)
}
}
@@ -15,24 +15,37 @@ 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
import chat.simplex.app.views.chat.item.*
@Composable
fun SendMsgView(sendMessage: (String) -> Unit) {
var cmd by remember { mutableStateOf("") }
var msg by remember { mutableStateOf("") }
val smallFont = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground)
var textStyle by remember { mutableStateOf(smallFont) }
BasicTextField(
value = cmd,
onValueChange = { cmd = it },
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
value = msg,
onValueChange = {
msg = it
textStyle = if(isShortEmoji(it)) {
if (it.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont
} else {
smallFont
}
},
textStyle = textStyle,
maxLines = 16,
keyboardOptions = KeyboardOptions.Default.copy(
capitalization = KeyboardCapitalization.Sentences,
autoCorrect = true
),
modifier = Modifier.padding(8.dp),
cursorBrush = SolidColor(HighOrLowlight),
decorationBox = { innerTextField ->
Surface(
shape = RoundedCornerShape(18.dp),
@@ -51,7 +64,7 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
) {
innerTextField()
}
val color = if (cmd.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
val color = if (msg.isNotEmpty()) MaterialTheme.colors.primary else Color.Gray
Icon(
Icons.Outlined.ArrowUpward,
"Send Message",
@@ -62,9 +75,10 @@ fun SendMsgView(sendMessage: (String) -> Unit) {
.clip(CircleShape)
.background(color)
.clickable {
if (cmd.isNotEmpty()) {
sendMessage(cmd)
cmd = ""
if (msg.isNotEmpty()) {
sendMessage(msg)
msg = ""
textStyle = smallFont
}
}
)
@@ -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
@@ -1,47 +1,88 @@
package chat.simplex.app.views.chat.item
import android.content.Context
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
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.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
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
import chat.simplex.app.model.ChatItem
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.copyText
import chat.simplex.app.views.helpers.shareText
import kotlinx.datetime.Clock
@ExperimentalTextApi
@Composable
fun ChatItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
fun ChatItemView(user: User, cItem: ChatItem, quotedItem: MutableState<ChatItem?>, cxt: Context, uriHandler: UriHandler? = null) {
val sent = cItem.chatDir.sent
val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart
var showMenu by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.padding(bottom = 4.dp)
.fillMaxWidth()
.padding(
start = if (sent) 60.dp else 16.dp,
end = if (sent) 16.dp else 60.dp,
start = if (sent) 86.dp else 16.dp,
end = if (sent) 16.dp else 86.dp,
),
contentAlignment = alignment,
) {
TextItemView(chatItem, uriHandler)
Column(Modifier.combinedClickable(onLongClick = { showMenu = true }, onClick = {})) {
if (cItem.quotedItem == null && isShortEmoji(cItem.content.text)) {
EmojiItemView(cItem)
} else {
FramedItemView(user, cItem, uriHandler)
}
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
ItemAction("Reply", Icons.Outlined.Reply, onClick = {
quotedItem.value = cItem
showMenu = false
})
ItemAction("Share", Icons.Outlined.Share, onClick = {
shareText(cxt, cItem.content.text)
showMenu = false
})
ItemAction("Copy", Icons.Outlined.ContentCopy, onClick = {
copyText(cxt, cItem.content.text)
showMenu = false
})
}
}
}
}
@Composable
private fun ItemAction(text: String, icon: ImageVector, onClick: () -> Unit) {
DropdownMenuItem(onClick) {
Row {
Text(text, modifier = Modifier
.fillMaxWidth()
.weight(1F))
Icon(icon, text, tint = HighOrLowlight)
}
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewChatItemView() {
SimpleXTheme {
ChatItemView(
chatItem = ChatItem.getSampleData(
User.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
),
quotedItem = remember { mutableStateOf(null) },
cxt = LocalContext.current
)
}
}
@@ -0,0 +1,42 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
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.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.ChatItem
val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp)
val mediumEmojiFont: TextStyle = TextStyle(fontSize = 36.sp)
@Composable
fun EmojiItemView(chatItem: ChatItem) {
Column(
Modifier.padding(vertical = 8.dp, horizontal = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(chatItem.content.text)
CIMetaView(chatItem)
}
}
@Composable
fun EmojiText(text: String) {
val s = text.trim()
Text(s, style = if (s.codePoints().count() < 4) largeEmojiFont else mediumEmojiFont)
}
private fun isSimpleEmoji(c: Int): Boolean = c > 0x238C
fun isEmoji(c: Int): Boolean = isSimpleEmoji(c) // || isCombinedIntoEmoji(c)
// TODO count perceived emojis, possibly using icu4j
fun isShortEmoji(str: String): Boolean {
val s = str.trim()
return s.codePoints().count() in 1..5 && s.codePoints().allMatch(::isEmoji)
}
@@ -0,0 +1,146 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
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.platform.UriHandler
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x20B1B0B5)
val SentQuoteColorLight = Color(0x2545B8FF)
val ReceivedQuoteColorLight = Color(0x25B1B0B5)
@Composable
fun FramedItemView(user: User, ci: ChatItem, uriHandler: UriHandler? = null) {
val sent = ci.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight
) {
Box(contentAlignment = Alignment.BottomEnd) {
Column(Modifier.width(IntrinsicSize.Max)) {
val qi = ci.quotedItem
if (qi != null) {
Box(
Modifier
.background(if (sent) SentQuoteColorLight else ReceivedQuoteColorLight)
.padding(vertical = 6.dp, horizontal = 12.dp)
.fillMaxWidth()
) {
MarkdownText(
qi, sender = qi.sender(user), senderBold = true, maxLines = 3,
style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)
)
}
}
Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) {
if (ci.formattedText == null && isShortEmoji(ci.content.text)) {
Column(
Modifier.padding(bottom = 2.dp).fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
EmojiText(ci.content.text)
Text("")
}
} else {
MarkdownText(
ci.content, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText, uriHandler = uriHandler, senderBold = true
)
}
}
}
Box(Modifier.padding(bottom = 6.dp, end = 12.dp)) {
CIMetaView(ci)
}
}
}
}
@Preview
@Composable
fun PreviewTextItemViewSnd() {
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewRcv() {
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewLong() {
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewQuote() {
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(),
Clock.System.now(),
"https://simplex.chat",
CIStatus.SndSent(),
quotedItem = CIQuote.getSample(1, Clock.System.now(), "hi", chatDir = CIDirection.DirectRcv())
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewEmoji() {
SimpleXTheme {
FramedItemView(
User.sampleData,
ChatItem.getSampleData(
1, CIDirection.DirectSnd(),
Clock.System.now(),
"👍",
CIStatus.SndSent(),
quotedItem = CIQuote.getSample(1, Clock.System.now(), "Lorem ipsum dolor sit amet", chatDir = CIDirection.DirectRcv())
)
)
}
}
@@ -1,47 +1,17 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.*
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.compose.ui.unit.sp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.LightGray
import chat.simplex.app.ui.theme.SimpleXTheme
import kotlinx.datetime.Clock
// TODO move to theme
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x1EB1B0B5)
@ExperimentalTextApi
@Composable
fun TextItemView(chatItem: ChatItem, uriHandler: UriHandler? = null) {
val sent = chatItem.chatDir.sent
Surface(
shape = RoundedCornerShape(18.dp),
color = if (sent) SentColorLight else ReceivedColorLight
) {
Box(
modifier = Modifier.padding(vertical = 6.dp, horizontal = 12.dp)
) {
Box(contentAlignment = Alignment.BottomEnd) {
MarkdownText(chatItem, uriHandler = uriHandler, groupMemberBold = true)
CIMetaView(chatItem)
}
}
}
}
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
@@ -55,34 +25,44 @@ fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMembe
}
}
@ExperimentalTextApi
fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) {
if (sender != null) {
if (senderBold) b.withStyle(boldFont) { append(sender) }
else b.append(sender)
b.append(": ")
}
}
@Composable
fun MarkdownText (
chatItem: ChatItem,
style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
content: ItemContent,
formattedText: List<FormattedText>? = null,
sender: String? = null,
metaText: String? = null,
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,
groupMemberBold: Boolean = false,
senderBold: Boolean = false,
modifier: Modifier = Modifier
) {
if (chatItem.formattedText == null) {
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
appendGroupMember(this, chatItem, groupMemberBold)
append(chatItem.content.text)
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
}
SelectionContainer {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
appendSender(this, sender, senderBold)
append(content.text)
if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") }
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
} else {
var hasLinks = false
val annotatedText = buildAnnotatedString {
appendGroupMember(this, chatItem, groupMemberBold)
for (ft in chatItem.formattedText) {
appendSender(this, sender, senderBold)
for (ft in formattedText) {
if (ft.format == null) append(ft.text)
else {
val link = ft.link
if (link != null) {
hasLinks = true
withAnnotation(tag = "URL", annotation = link) {
withStyle(ft.format.style) { append(ft.text) }
}
@@ -91,63 +71,17 @@ fun MarkdownText (
}
}
}
withStyle(reserveTimestampStyle) { append(" ${chatItem.timestampText}") }
if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") }
}
if (uriHandler != null) {
SelectionContainer {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
}
)
}
if (hasLinks && uriHandler != null) {
ClickableText(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow,
onClick = { offset ->
annotatedText.getStringAnnotations(tag = "URL", start = offset, end = offset)
.firstOrNull()?.let { annotation -> uriHandler.openUri(annotation.item) }
}
)
} else {
SelectionContainer {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewSnd() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewRcv() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
}
}
@ExperimentalTextApi
@Preview
@Composable
fun PreviewTextItemViewLong() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1,
CIDirection.DirectSnd(),
Clock.System.now(),
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
)
)
}
}
@@ -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("connect to SimpleX Chat founder")
}
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 {
@@ -41,9 +39,11 @@ fun ChatPreviewView(chat: Chat) {
fontWeight = FontWeight.Bold
)
if (chat.chatItems.count() > 0) {
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
chat.chatItems.last(),
ci.content, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
@@ -78,7 +78,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,7 @@
package chat.simplex.app.views.helpers
import android.content.Context
import android.content.Intent
import android.content.*
import androidx.core.content.ContextCompat
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
@@ -12,3 +12,8 @@ fun shareText(cxt: Context, text: String) {
val shareIntent = Intent.createChooser(sendIntent, null)
cxt.startActivity(shareIntent)
}
fun copyText(cxt: Context, text: String) {
val clipboard = ContextCompat.getSystemService(cxt, ClipboardManager::class.java)
clipboard?.setPrimaryClip(ClipData.newPlainText("text", text))
}
@@ -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,301 @@
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.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
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,22 @@ 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.BuildConfig
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 +43,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 +62,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 +82,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 +92,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 +100,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",
@@ -112,7 +117,7 @@ fun SettingsLayout(
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Get help & advice via chat",
"Chat with the founder",
color = MaterialTheme.colors.primary
)
}
@@ -124,13 +129,22 @@ fun SettingsLayout(
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Ask questions via email",
"Send us email",
color = MaterialTheme.colors.primary
)
}
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",
@@ -154,17 +168,21 @@ fun SettingsLayout(
}
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(click = {}) {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
}
}
@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 +201,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)
+64 -17
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 {
@@ -356,9 +360,9 @@ final class Chat: ObservableObject, Identifiable {
var statusExplanation: String {
get {
switch self {
case .connected: return "You are connected to the server you use to receve messages from this contact."
case let .error(err): return "Trying to connect to the server you use to receve messages from this contact (error: \(err))."
default: return "Trying to connect to the server you use to receve messages from this contact."
case .connected: return "You are connected to the server used to receive messages from this contact."
case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))."
default: return "Trying to connect to the server used to receive messages from this contact."
}
}
}
@@ -523,21 +527,33 @@ struct ChatItem: Identifiable, Decodable {
var meta: CIMeta
var content: CIContent
var formattedText: [FormattedText]?
var quotedItem: CIQuote?
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 }
return false
}
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew) -> ChatItem {
var memberDisplayName: String? {
get {
if case let .groupRcv(groupMember) = chatDir {
return groupMember.memberProfile.displayName
} else {
return nil
}
}
}
static func getSample (_ id: Int64, _ dir: CIDirection, _ ts: Date, _ text: String, _ status: CIStatus = .sndNew, quotedItem: CIQuote? = nil) -> ChatItem {
ChatItem(
chatDir: dir,
meta: CIMeta.getSample(id, ts, text, status),
content: .sndMsgContent(msgContent: .text(text))
content: .sndMsgContent(msgContent: .text(text)),
quotedItem: quotedItem
)
}
}
@@ -567,7 +583,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 +596,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 {
@@ -598,7 +615,11 @@ enum CIStatus: Decodable {
case rcvRead
}
enum CIContent: Decodable {
protocol ItemContent {
var text: String { get }
}
enum CIContent: Decodable, ItemContent {
case sndMsgContent(msgContent: MsgContent)
case rcvMsgContent(msgContent: MsgContent)
case sndFileInvitation(fileId: Int64, filePath: String)
@@ -620,17 +641,43 @@ struct RcvFileTransfer: Decodable {
}
struct CIQuote: Decodable, ItemContent {
var chatDir: CIDirection?
var itemId: Int64?
var sharedMsgId: String? = nil
var sentAt: Date
var content: MsgContent
var formattedText: [FormattedText]?
var text: String { get { content.text } }
var sender: String? {
get {
switch (chatDir) {
case .directSnd: return "you"
case .directRcv: return nil
case .groupSnd: return ChatModel.shared.currentUser?.displayName
case let .groupRcv(member): return member.memberProfile.displayName
case nil: return nil
}
}
}
static func getSample(_ itemId: Int64?, _ sentAt: Date, _ text: String, chatDir: CIDirection?) -> CIQuote {
CIQuote(chatDir: chatDir, itemId: itemId, sentAt: sentAt, content: .text(text))
}
}
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 +699,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 +711,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")
}
}
}
+18 -1
View File
@@ -162,11 +162,27 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
addNotification(
categoryIdentifier: ntfCategoryMessageReceived,
title: "\(cInfo.chatViewName):",
body: cItem.content.text,
body: hideSecrets(cItem),
targetContentIdentifier: cInfo.id
// userInfo: ["chatId": cInfo.id, "chatItemId": cItem.id]
)
}
func hideSecrets(_ cItem: ChatItem) -> String {
if let md = cItem.formattedText {
var res = ""
for ft in md {
if case .secret = ft.format {
res = res + "..."
} else {
res = res + ft.text
}
}
return res
} else {
return cItem.content.text
}
}
private func addNotification(categoryIdentifier: String, title: String, subtitle: String? = nil, body: String? = nil,
targetContentIdentifier: String? = nil, userInfo: [AnyHashable : Any] = [:]) {
@@ -178,6 +194,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
+123 -12
View File
@@ -22,6 +22,9 @@ enum ChatCommand {
case apiGetChats
case apiGetChat(type: ChatType, id: Int64)
case apiSendMessage(type: ChatType, id: Int64, msg: MsgContent)
case apiSendMessageQuote(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent)
case getUserSMPServers
case setUserSMPServers(smpServers: [String])
case addContact
case connect(connReq: String)
case apiDeleteChat(type: ChatType, id: Int64)
@@ -43,6 +46,9 @@ 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 let .apiSendMessageQuote(type, id, itemId, mc): return "/_send_quote \(ref(type, id)) \(itemId) \(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 +73,9 @@ enum ChatCommand {
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiSendMessage: return "apiSendMessage"
case .apiSendMessageQuote: return "apiSendMessageQuote"
case .getUserSMPServers: return "getUserSMPServers"
case .setUserSMPServers: return "setUserSMPServers"
case .addContact: return "addContact"
case .connect: return "connect"
case .apiDeleteChat: return "apiDeleteChat"
@@ -85,6 +94,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 +108,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 +146,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 +187,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 +257,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 +315,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
}
}
}
@@ -300,14 +365,48 @@ func apiGetChat(type: ChatType, id: Int64) async throws -> Chat {
throw r
}
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 }
func apiSendMessage(type: ChatType, id: Int64, quotedItemId: Int64?, msg: MsgContent) async throws -> ChatItem {
let chatModel = ChatModel.shared
let cmd: ChatCommand
if let itemId = quotedItemId {
cmd = .apiSendMessageQuote(type: type, id: id, itemId: itemId, msg: msg)
} else {
cmd = .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 +421,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 +546,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 +606,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 +682,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)
}
@@ -12,25 +12,22 @@ struct EmojiItemView: View {
var chatItem: ChatItem
var body: some View {
let sent = chatItem.chatDir.sent
let s = chatItem.content.text.trimmingCharacters(in: .whitespaces)
VStack(spacing: 1) {
Text(s)
.font(s.count < 4 ? largeEmojiFont : mediumEmojiFont)
emojiText(chatItem.content.text)
.padding(.top, 8)
.padding(.horizontal, 6)
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
CIMetaView(chatItem: chatItem)
.padding(.bottom, 8)
.padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
}
.padding(.horizontal)
.frame(maxWidth: .infinity, alignment: sent ? .trailing : .leading)
}
}
func emojiText(_ text: String) -> Text {
let s = text.trimmingCharacters(in: .whitespaces)
return Text(s).font(s.count < 4 ? largeEmojiFont : mediumEmojiFont)
}
struct EmojiItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
@@ -0,0 +1,112 @@
//
// FramedItemView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 04/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
private let sentQuoteColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.11)
private let sentQuoteColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.09)
struct FramedItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
@State var msgWidth: CGFloat = 0
var body: some View {
let v = ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading, spacing: 0) {
if let qi = chatItem.quotedItem {
MsgContentView(
content: qi,
sender: qi.sender
)
.lineLimit(3)
.font(.subheadline)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.frame(minWidth: msgWidth, alignment: .leading)
.background(
chatItem.chatDir.sent
? (colorScheme == .light ? sentQuoteColorLight : sentQuoteColorDark)
: Color(uiColor: .quaternarySystemFill)
)
.overlay(DetermineWidth())
}
if chatItem.formattedText == nil && isShortEmoji(chatItem.content.text) {
VStack {
emojiText(chatItem.content.text)
Text("")
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
.overlay(DetermineWidth())
.frame(minWidth: msgWidth, alignment: .center)
.padding(.bottom, 2)
} else {
MsgContentView(
content: chatItem.content,
formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName,
metaText: chatItem.timestampText
)
.padding(.vertical, 6)
.padding(.horizontal, 12)
.overlay(DetermineWidth())
.frame(minWidth: 0, alignment: .leading)
.textSelection(.enabled)
}
}
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
CIMetaView(chatItem: chatItem)
.padding(.trailing, 12)
.padding(.bottom, 6)
}
.background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18)
switch chatItem.meta.itemStatus {
case .sndErrorAuth:
v.onTapGesture { msgDeliveryError("Most likely this contact has deleted the connection with you.") }
case let .sndError(agentError):
v.onTapGesture { msgDeliveryError("Unexpected error: \(String(describing: agentError))") }
default: v
}
}
private func msgDeliveryError(_ err: String) {
AlertManager.shared.showAlertMsg(
title: "Message delivery error",
message: err
)
}
}
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
ci.chatDir.sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
}
struct FramedItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
FramedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
FramedItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)))
FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)))
FramedItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)))
FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"))
FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "))
FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"))
FramedItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"))
}
.previewLayout(.fixed(width: 360, height: 200))
}
}
@@ -0,0 +1,97 @@
//
// MsgContentView.swift
// SimpleX
//
// Created by Evgeny on 13/03/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let linkColor = Color(uiColor: uiLinkColor)
struct MsgContentView: View {
var content: ItemContent
var formattedText: [FormattedText]? = nil
var sender: String? = nil
var metaText: Text? = nil
var body: some View {
let v = messageText(content, formattedText, sender)
if let mt = metaText {
return v + reserveSpaceForMeta(mt)
} else {
return v
}
}
private func reserveSpaceForMeta(_ meta: Text) -> Text {
(Text(" ") + meta)
.font(.caption)
.foregroundColor(.clear)
}
}
func messageText(_ content: ItemContent, _ formattedText: [FormattedText]?, _ sender: String?, preview: Bool = false) -> Text {
let s = content.text
var res: Text
if let ft = formattedText, ft.count > 0 {
res = formattText(ft[0], preview)
var i = 1
while i < ft.count {
res = res + formattText(ft[i], preview)
i = i + 1
}
} else {
res = Text(s)
}
if let s = sender {
let t = Text(s)
return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res
} else {
return res
}
}
private func formattText(_ ft: FormattedText, _ preview: Bool) -> Text {
let t = ft.text
if let f = ft.format {
switch (f) {
case .bold: return Text(t).bold()
case .italic: return Text(t).italic()
case .strikeThrough: return Text(t).strikethrough()
case .snippet: return Text(t).font(.body.monospaced())
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "")
case .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
}
} else {
return Text(t)
}
}
private func linkText(_ s: String, _ link: String,
_ preview: Bool, prefix: String) -> Text {
preview
? Text(s).foregroundColor(linkColor).underline(color: linkColor)
: Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + link) as Any,
.foregroundColor: uiLinkColor as Any
]))).underline()
}
struct MsgContentView_Previews: PreviewProvider {
static var previews: some View {
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return MsgContentView(
content: chatItem.content,
formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName,
metaText: chatItem.timestampText
)
}
}
@@ -1,136 +0,0 @@
//
// TextItemView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 04/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let sentColorLight = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.12)
private let sentColorDark = Color(.sRGB, red: 0.27, green: 0.72, blue: 1, opacity: 0.17)
private let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let linkColor = Color(uiColor: uiLinkColor)
struct TextItemView: View {
@Environment(\.colorScheme) var colorScheme
var chatItem: ChatItem
var width: CGFloat
private let codeFont = Font.custom("Courier", size: UIFont.preferredFont(forTextStyle: .body).pointSize)
var body: some View {
let sent = chatItem.chatDir.sent
let maxWidth = width * 0.78
return ZStack(alignment: .bottomTrailing) {
(messageText(chatItem) + reserveSpaceForMeta(chatItem.timestampText))
.padding(.vertical, 6)
.padding(.horizontal, 12)
.frame(minWidth: 0, alignment: .leading)
.textSelection(.enabled)
CIMetaView(chatItem: chatItem)
.padding(.trailing, 12)
.padding(.bottom, 6)
}
.background(
sent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
)
.cornerRadius(18)
.padding(.horizontal)
.frame(
maxWidth: maxWidth,
maxHeight: .infinity,
alignment: sent ? .trailing : .leading
)
.onTapGesture {
switch chatItem.meta.itemStatus {
case .sndErrorAuth: msgDeliveryError("Most likely this contact has deleted the connection with you.")
case let .sndError(agentError): msgDeliveryError("Unexpected error: \(String(describing: agentError))")
default: return
}
}
}
private func reserveSpaceForMeta(_ meta: String) -> Text {
Text(" \(meta)")
.font(.caption)
.foregroundColor(.clear)
}
private func msgDeliveryError(_ err: String) {
AlertManager.shared.showAlertMsg(
title: "Message delivery error",
message: err
)
}
}
func messageText(_ chatItem: ChatItem, preview: Bool = false) -> Text {
let s = chatItem.content.text
var res: Text
if let ft = chatItem.formattedText, ft.count > 0 {
res = formattedText(ft[0], preview)
var i = 1
while i < ft.count {
res = res + formattedText(ft[i], preview)
i = i + 1
}
} else {
res = Text(s)
}
if case let .groupRcv(groupMember) = chatItem.chatDir {
let m = Text(groupMember.memberProfile.displayName)
return (preview ? m : m.font(.headline)) + Text(": ") + res
} else {
return res
}
}
private func formattedText(_ ft: FormattedText, _ preview: Bool) -> Text {
let t = ft.text
if let f = ft.format {
switch (f) {
case .bold: return Text(t).bold()
case .italic: return Text(t).italic()
case .strikeThrough: return Text(t).strikethrough()
case .snippet: return Text(t).font(.body.monospaced())
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "")
case .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
}
} else {
return Text(t)
}
}
private func linkText(_ s: String, _ link: String,
_ preview: Bool, prefix: String) -> Text {
preview
? Text(s).foregroundColor(linkColor).underline(color: linkColor)
: Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + link) as Any,
.foregroundColor: uiLinkColor as Any
]))).underline()
}
struct TextItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
TextItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360)
TextItemView(chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello"), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), width: 360)
TextItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), width: 360)
}
.previewLayout(.fixed(width: 360, height: 70))
}
}
@@ -10,13 +10,12 @@ import SwiftUI
struct ChatItemView: View {
var chatItem: ChatItem
var width: CGFloat
var body: some View {
if (isShortEmoji(chatItem.content.text)) {
if (chatItem.quotedItem == nil && isShortEmoji(chatItem.content.text)) {
EmojiItemView(chatItem: chatItem)
} else {
TextItemView(chatItem: chatItem, width: width)
FramedItemView(chatItem: chatItem)
}
}
}
@@ -24,11 +23,11 @@ struct ChatItemView: View {
struct ChatItemView_Previews: PreviewProvider {
static var previews: some View {
Group{
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), width: 360)
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"))
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"))
ChatItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"))
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"))
ChatItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"))
}
.previewLayout(.fixed(width: 360, height: 70))
}
+29 -6
View File
@@ -12,6 +12,7 @@ struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
@State var quotedItem: ChatItem? = nil
@State private var inProgress: Bool = false
@FocusState private var keyboardVisible: Bool
@State private var showChatInfo = false
@@ -21,12 +22,27 @@ struct ChatView: View {
return VStack {
GeometryReader { g in
let maxWidth = g.size.width * 0.78
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: 5) {
ForEach(chatModel.chatItems, id: \.id) {
ChatItemView(chatItem: $0, width: g.size.width)
.frame(minWidth: 0, maxWidth: .infinity, alignment: $0.chatDir.sent ? .trailing : .leading)
LazyVStack(spacing: 5) {
ForEach(chatModel.chatItems) { ci in
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
ChatItemView(chatItem: ci)
.contextMenu {
Button {
withAnimation { quotedItem = ci }
} label: { Label("Reply", systemImage: "arrowshape.turn.up.left") }
Button {
showShareSheet(items: [ci.content.text])
} label: { Label("Share", systemImage: "square.and.arrow.up") }
Button {
UIPasteboard.general.string = ci.content.text
} label: { Label("Copy", systemImage: "doc.on.doc") }
}
.padding(.horizontal)
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
}
.onAppear {
DispatchQueue.main.async {
@@ -54,7 +70,8 @@ struct ChatView: View {
Spacer(minLength: 0)
SendMessageView(
ComposeView(
quotedItem: $quotedItem,
sendMessage: sendMessage,
inProgress: inProgress,
keyboardVisible: $keyboardVisible
@@ -115,8 +132,14 @@ struct ChatView: View {
func sendMessage(_ msg: String) {
Task {
do {
let chatItem = try await apiSendMessage(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, msg: .text(msg))
let chatItem = try await apiSendMessage(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
quotedItemId: quotedItem?.meta.itemId,
msg: .text(msg)
)
DispatchQueue.main.async {
quotedItem = nil
chatModel.addChatItem(chat.chatInfo, chatItem)
}
} catch {
@@ -0,0 +1,42 @@
//
// ComposeView.swift
// SimpleX
//
// Created by Evgeny on 13/03/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct ComposeView: View {
@Binding var quotedItem: ChatItem?
var sendMessage: (String) -> Void
var inProgress: Bool = false
@FocusState.Binding var keyboardVisible: Bool
var body: some View {
VStack(spacing: 0) {
QuotedItemView(quotedItem: $quotedItem)
.transition(.move(edge: .bottom))
SendMessageView(
sendMessage: sendMessage,
inProgress: inProgress,
keyboardVisible: $keyboardVisible
)
.background(.background)
}
}
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
@FocusState var keyboardVisible: Bool
@State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
return ComposeView(
quotedItem: $quotedItem,
sendMessage: { print ($0) },
keyboardVisible: $keyboardVisible
)
}
}
@@ -0,0 +1,49 @@
//
// QuotedItemView.swift
// SimpleX
//
// Created by Evgeny on 13/03/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct QuotedItemView: View {
@Environment(\.colorScheme) var colorScheme
@Binding var quotedItem: ChatItem?
var body: some View {
if let qi = quotedItem {
HStack {
quoteText(qi).lineLimit(3)
Spacer()
Button {
withAnimation { quotedItem = nil }
} label: {
Image(systemName: "multiply")
}
}
.padding(12)
.frame(maxWidth: .infinity)
.background(chatItemFrameColor(qi, colorScheme))
.padding(.top, 8)
} else {
EmptyView()
}
}
func quoteText(_ qi: ChatItem) -> some View {
if let s = qi.memberDisplayName {
return (Text(s).fontWeight(.medium) + Text(": \(qi.content.text)"))
} else {
return Text(qi.content.text)
}
}
}
struct QuotedItemView_Previews: PreviewProvider {
static var previews: some View {
@State var quotedItem: ChatItem? = ChatItem.getSample(1, .directSnd, .now, "hello")
return QuotedItemView(quotedItem: $quotedItem)
}
}
@@ -18,7 +18,7 @@ struct ChatHelp: View {
HStack(spacing: 4) {
Text("You can")
Button("connect to SimpleX team.") {
Button("connect to SimpleX Chat founder.") {
showSettings = false
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
@@ -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)
@@ -51,7 +51,7 @@ struct ChatPreviewView: View {
if let cItem = cItem {
ZStack(alignment: .topTrailing) {
(itemStatusMark(cItem) + messageText(cItem, preview: true))
(itemStatusMark(cItem) + messageText(cItem.content, cItem.formattedText, cItem.memberDisplayName, preview: true))
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
@@ -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)
@@ -0,0 +1,35 @@
//
// DetermineWidth.swift
// SimpleX
//
// Created by Evgeny on 14/03/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
struct DetermineWidth: View {
typealias Key = MaximumWidthPreferenceKey
var body: some View {
GeometryReader {
proxy in
Color.clear
.anchorPreference(key: Key.self, value: .bounds) {
anchor in proxy[anchor].size.width
}
}
}
}
struct MaximumWidthPreferenceKey: PreferenceKey {
static var defaultValue: CGFloat = 0
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
value = max(value, nextValue())
}
}
struct DetermineWidth_Previews: PreviewProvider {
static var previews: some View {
DetermineWidth()
}
}
@@ -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)
}
}
@@ -10,6 +10,10 @@ import SwiftUI
let simplexTeamURL = URL(string: "simplex:/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D")!
let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String
struct SettingsView: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var chatModel: ChatModel
@@ -47,6 +51,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 {
@@ -80,13 +97,13 @@ struct SettingsView: View {
UIApplication.shared.open(simplexTeamURL)
}
} label: {
Text("Get help & advice via chat")
Text("Chat with the founder")
}
}
HStack {
Image(systemName: "envelope")
.padding(.trailing, 4)
Text("[Ask questions via email](mailto:chat@simplex.chat)")
Text("[Send us email](mailto:chat@simplex.chat)")
}
}
@@ -108,11 +125,8 @@ struct SettingsView: View {
.padding(.trailing, 8)
Text("Install [SimpleX Chat for terminal](https://github.com/simplex-chat/simplex-chat)")
}
Text("v\(appVersion ?? "?") (\(appBuild ?? "?"))")
}
// Section("Your SimpleX servers") {
//
// }
}
.navigationTitle("Your settings")
}
+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()
+81 -45
View File
@@ -9,20 +9,20 @@
/* Begin PBXBuildFile section */
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C063D2627A4564100AEC577 /* ChatPreviewView.swift */; };
5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; };
5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */; };
5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; };
5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF227E24676003DE3D0 /* libffi.a */; };
5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; };
5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */; };
5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; };
5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF427E24676003DE3D0 /* libgmp.a */; };
5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; };
5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0E5EF527E24676003DE3D0 /* libgmpxx.a */; };
5C116CDC27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C116CDD27AABE0400E66D01 /* ContactRequestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C116CDB27AABE0400E66D01 /* ContactRequestView.swift */; };
5C1A4C1E27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
5C1A4C1F27A715B700EAD5AD /* ChatItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */; };
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 */; };
@@ -35,6 +35,10 @@
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFC727B2782E00FB6C6D /* BGManager.swift */; };
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */; };
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; };
5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */; };
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; };
5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C3A88D027DF57800060F1C2 /* FramedItemView.swift */; };
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5346A727B59A6A004DF848 /* ChatHelp.swift */; };
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
@@ -98,10 +102,16 @@
5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */; };
5CE4407227ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; };
5CE4407327ADB1D0007B033A /* Emoji.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407127ADB1D0007B033A /* Emoji.swift */; };
5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CE4407527ADB66A007B033A /* TextItemView.swift */; };
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 */; };
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE227DE9246000BD591 /* ComposeView.swift */; };
5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; };
5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */; };
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CEACCEC27DEA495000BD591 /* MsgContentView.swift */; };
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -123,20 +133,21 @@
/* Begin PBXFileReference section */
5C063D2627A4564100AEC577 /* ChatPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPreviewView.swift; sourceTree = "<group>"; };
5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a"; sourceTree = "<group>"; };
5C0E5EF227E24676003DE3D0 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a"; sourceTree = "<group>"; };
5C0E5EF427E24676003DE3D0 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C0E5EF527E24676003DE3D0 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; 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>"; };
5C2E260E27A30FDC00F70299 /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = "<group>"; };
5C2E261127A30FEA00F70299 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
5C35CFC727B2782E00FB6C6D /* BGManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BGManager.swift; sourceTree = "<group>"; };
5C35CFCA27B2E91D00FB6C6D /* NtfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NtfManager.swift; sourceTree = "<group>"; };
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetermineWidth.swift; sourceTree = "<group>"; };
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FramedItemView.swift; sourceTree = "<group>"; };
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "SimpleX (iOS).entitlements"; sourceTree = "<group>"; };
5C5346A727B59A6A004DF848 /* ChatHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatHelp.swift; sourceTree = "<group>"; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
@@ -177,8 +188,11 @@
5CCD403627A5F9A200368C90 /* ConnectContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectContactView.swift; sourceTree = "<group>"; };
5CCD403927A5F9BE00368C90 /* CreateGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreateGroupView.swift; sourceTree = "<group>"; };
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>"; };
5CEACCE227DE9246000BD591 /* ComposeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeView.swift; sourceTree = "<group>"; };
5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotedItemView.swift; sourceTree = "<group>"; };
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MsgContentView.swift; sourceTree = "<group>"; };
640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -186,14 +200,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 */,
5C0E5EFC27E24676003DE3D0 /* libgmp.a in Frameworks */,
5C764E83279C748B000C6508 /* libz.tbd in Frameworks */,
5C27D00E27C7D8B500DD6182 /* libffi.a in Frameworks */,
5C27D00C27C7D8B500DD6182 /* libHSsimplex-chat-1.2.1-KSWVFEZPyZUBOUtDs8BKKY.a in Frameworks */,
5C0E5EF627E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */,
5C0E5EFE27E24676003DE3D0 /* libgmpxx.a in Frameworks */,
5C0E5EF827E24676003DE3D0 /* libffi.a in Frameworks */,
5C764E82279C748B000C6508 /* libiconv.tbd in Frameworks */,
5C0E5EFA27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -201,13 +215,13 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
5C27D00927C7D8B500DD6182 /* libgmpxx.a in Frameworks */,
5C27D00F27C7D8B500DD6182 /* libffi.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 */,
5C0E5EF727E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a in Frameworks */,
5C764E84279C748C000C6508 /* libiconv.tbd in Frameworks */,
5C0E5EFB27E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a in Frameworks */,
5C0E5EFD27E24676003DE3D0 /* libgmp.a in Frameworks */,
5C0E5EF927E24676003DE3D0 /* libffi.a in Frameworks */,
5C0E5EFF27E24676003DE3D0 /* libgmpxx.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -246,10 +260,10 @@
isa = PBXGroup;
children = (
5CE4407427ADB657007B033A /* ChatItem */,
5CEACCE527DE977C000BD591 /* ComposeMessage */,
5C2E260E27A30FDC00F70299 /* ChatView.swift */,
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */,
5C971E1C27AEBEF600C8A3CE /* ChatInfoView.swift */,
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */,
5C1A4C1D27A715B700EAD5AD /* ChatItemView.swift */,
5CE4407127ADB1D0007B033A /* Emoji.swift */,
);
@@ -259,11 +273,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 */,
5C0E5EF227E24676003DE3D0 /* libffi.a */,
5C0E5EF427E24676003DE3D0 /* libgmp.a */,
5C0E5EF527E24676003DE3D0 /* libgmpxx.a */,
5C0E5EF327E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj-ghc8.10.7.a */,
5C0E5EF127E24676003DE3D0 /* libHSsimplex-chat-1.3.2-6OWqTXVUCEWLoNzSi0aRKj.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -295,6 +309,7 @@
5C971E2027AEBF8300C8A3CE /* ChatInfoImage.swift */,
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */,
5CC1C99427A6CF7F000D9FF6 /* ShareSheet.swift */,
5C3A88CD27DF50170060F1C2 /* DetermineWidth.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -324,7 +339,6 @@
5C764E7D279C7275000C6508 /* SimpleX (iOS)-Bridging-Header.h */,
5C764E7E279C7275000C6508 /* SimpleX (macOS)-Bridging-Header.h */,
5C764E7F279C7276000C6508 /* dummy.m */,
5C2E260927A2C63500F70299 /* MyPlayground.playground */,
);
path = Shared;
sourceTree = "<group>";
@@ -385,6 +399,7 @@
5CB924E327A8683A00ACCCDD /* UserAddress.swift */,
5CB924E027A867BA00ACCCDD /* UserProfile.swift */,
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */,
640F50E227CF991C001E05C2 /* SMPServers.swift */,
);
path = UserSettings;
sourceTree = "<group>";
@@ -404,13 +419,24 @@
5CE4407427ADB657007B033A /* ChatItem */ = {
isa = PBXGroup;
children = (
5CE4407527ADB66A007B033A /* TextItemView.swift */,
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */,
5CE4407827ADB701007B033A /* EmojiItemView.swift */,
5CEACCEC27DEA495000BD591 /* MsgContentView.swift */,
5C3A88D027DF57800060F1C2 /* FramedItemView.swift */,
);
path = ChatItem;
sourceTree = "<group>";
};
5CEACCE527DE977C000BD591 /* ComposeMessage */ = {
isa = PBXGroup;
children = (
5C9FD96D27A5D6ED0075386C /* SendMessageView.swift */,
5CEACCE227DE9246000BD591 /* ComposeView.swift */,
5CEACCE627DE97B6000BD591 /* QuotedItemView.swift */,
);
path = ComposeMessage;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -580,13 +606,16 @@
files = (
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
5CB924D727A8563F00ACCCDD /* SettingsView.swift in Sources */,
5CE4407627ADB66A007B033A /* TextItemView.swift in Sources */,
5CEACCE327DE9246000BD591 /* ComposeView.swift in Sources */,
5CB924E127A867BA00ACCCDD /* UserProfile.swift in Sources */,
5CE4407927ADB701007B033A /* EmojiItemView.swift in Sources */,
5C5346A827B59A6A004DF848 /* ChatHelp.swift in Sources */,
5C764E80279C7276000C6508 /* dummy.m in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
5CB924E427A8683A00ACCCDD /* UserAddress.swift in Sources */,
640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */,
5CEACCE727DE97B6000BD591 /* QuotedItemView.swift in Sources */,
5C063D2727A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCB27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261227A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -597,6 +626,7 @@
5CB9250D27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,
5CA059ED279559F40002BEB4 /* ContentView.swift in Sources */,
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */,
5C3A88CE27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */,
5C35CFC827B2782E00FB6C6D /* BGManager.swift in Sources */,
@@ -608,6 +638,7 @@
5CA059EB279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403727A5F9A200368C90 /* ConnectContactView.swift in Sources */,
5CCD403A27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
5CEACCED27DEA495000BD591 /* MsgContentView.swift in Sources */,
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */,
5C971E1D27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */,
5CC1C99527A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
@@ -624,13 +655,16 @@
files = (
5C6AD81427A834E300348BD7 /* NewChatButton.swift in Sources */,
5CB924D827A8563F00ACCCDD /* SettingsView.swift in Sources */,
5CE4407727ADB66A007B033A /* TextItemView.swift in Sources */,
5CEACCE427DE9246000BD591 /* ComposeView.swift in Sources */,
5CB924E227A867BA00ACCCDD /* UserProfile.swift in Sources */,
5CE4407A27ADB701007B033A /* EmojiItemView.swift in Sources */,
5C5346A927B59A6A004DF848 /* ChatHelp.swift in Sources */,
5C764E81279C7276000C6508 /* dummy.m in Sources */,
5C7505A927B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5C3A88D227DF57800060F1C2 /* FramedItemView.swift in Sources */,
5CB924E527A8683A00ACCCDD /* UserAddress.swift in Sources */,
640F50E427CF991C001E05C2 /* SMPServers.swift in Sources */,
5CEACCE827DE97B6000BD591 /* QuotedItemView.swift in Sources */,
5C063D2827A4564100AEC577 /* ChatPreviewView.swift in Sources */,
5C35CFCC27B2E91D00FB6C6D /* NtfManager.swift in Sources */,
5C2E261327A30FEA00F70299 /* TerminalView.swift in Sources */,
@@ -641,6 +675,7 @@
5CB9250E27A9432000ACCCDD /* ChatListNavLink.swift in Sources */,
5CA059EE279559F40002BEB4 /* ContentView.swift in Sources */,
5CCD403527A5F6DF00368C90 /* AddContactView.swift in Sources */,
5C3A88CF27DF50170060F1C2 /* DetermineWidth.swift in Sources */,
5C7505A627B679EE00BE3227 /* NavLinkPlain.swift in Sources */,
5C7505A327B65FDB00BE3227 /* CIMetaView.swift in Sources */,
5C35CFC927B2782E00FB6C6D /* BGManager.swift in Sources */,
@@ -652,6 +687,7 @@
5CA059EC279559F40002BEB4 /* SimpleXApp.swift in Sources */,
5CCD403827A5F9A200368C90 /* ConnectContactView.swift in Sources */,
5CCD403B27A5F9BE00368C90 /* CreateGroupView.swift in Sources */,
5CEACCEE27DEA495000BD591 /* MsgContentView.swift in Sources */,
5C764E8A279CBCB3000C6508 /* ChatModel.swift in Sources */,
5C971E1E27AEBEF600C8A3CE /* ChatInfoView.swift in Sources */,
5CC1C99627A6CF7F000D9FF6 /* ShareSheet.swift in Sources */,
@@ -815,7 +851,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -825,7 +861,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 +871,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.3;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -855,7 +891,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 28;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES;
@@ -865,7 +901,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 +911,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.3;
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.
+5 -5
View File
@@ -2,20 +2,20 @@ packages: .
source-repository-package
type: git
location: git://github.com/simplex-chat/simplexmq.git
tag: d1e6147adfbd46f5e3e996cc6365d8f3f0f7669c
location: https://github.com/simplex-chat/simplexmq.git
tag: 5c6ec96d6477371d8e617bcc71e6ecbcdd5c78cc
source-repository-package
type: git
location: git://github.com/simplex-chat/aeson.git
location: https://github.com/simplex-chat/aeson.git
tag: 3eb66f9a68f103b5f1489382aad89f5712a64db7
source-repository-package
type: git
location: git://github.com/simplex-chat/haskell-terminal.git
location: https://github.com/simplex-chat/haskell-terminal.git
tag: f708b00009b54890172068f168bf98508ffcd495
source-repository-package
type: git
location: git://github.com/zw3rk/android-support.git
location: https://github.com/zw3rk/android-support.git
tag: 3c3a5ab0b8b137a072c98d3d0937cbdc96918ddb
+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.3
#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.
+61
View File
@@ -0,0 +1,61 @@
# Message replies and chat item sequential numbers
## Problem
Many chat features require referring to the previous chat items in the same conversation:
- item editing
- item deletion
- item reply (with quoting)
- delivery/read receipts
- any interactive features mutating chat item state
- group message integrity via DAG
The most in-demand feature is replies.
## Proposed solution
As group message integrity is needed not for chat items, but for messages, the updated proposal is to introduce a random, non-sequential message id, unique per conversation and per sender.
All above features would rely on this ID, e.g. reply would use the ID of the message that created the item.
We will add an optional property `msgId` into all chat messages (not only visible to the users) and `msgRef` into messages that need to reference other messages.
`msgId` property is a base64 encoded 12 byte binary
JTD for quoting messages:
```yaml
definitions:
msgRef:
discriminator: type
mapping:
direct:
properties:
msgId: type: string
sentAt: type: datetime
sent: type: boolean # true if it is in reference to the item that the sender of the message originally sent, false for references to received items
group:
properties:
msgId: type: string
sentAt: type: datetime
memberId: type: string # base64 member ID of the sender known to all group members for group chats
content:
properties:
type: type: string
text: type: string
properties:
msgId: string
event: enum: ["x.msg.new"]
params:
properties:
content: ref: content
quote:
properties:
content: ref: content
msgRef: ref: msgRef
```
This format ensures that replies with quoting show as normal messages on the clients that do not support showing quotes (`quote` property will be ignored).
The only feature that would not work in case chatItem/chatItemRef is missing is navigating to the message to which the message is in reply to.
+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";
+5 -2
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.3
category: Web, System, Services, Cryptography
homepage: https://github.com/simplex-chat/simplex-chat#readme
author: simplex.chat
@@ -29,6 +29,9 @@ 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.Migrations.M20220304_msg_quotes
Simplex.Chat.Mobile
Simplex.Chat.Options
Simplex.Chat.Protocol
@@ -124,7 +127,7 @@ test-suite simplex-chat-test
Paths_simplex_chat
hs-source-dirs:
tests
ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns
ghc-options: -Wall -Wcompat -Werror=incomplete-patterns -Wredundant-constraints -Wincomplete-record-updates -Wincomplete-uni-patterns -Wunused-type-patterns -threaded
build-depends:
aeson ==2.0.*
, ansi-terminal >=0.10 && <0.12
+223 -119
View File
@@ -19,18 +19,22 @@ import Control.Monad.Except
import Control.Monad.IO.Unlift
import Control.Monad.Reader
import Crypto.Random (drgNew)
import qualified Data.Aeson as J
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 +43,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,10 +54,10 @@ 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)
import Simplex.Messaging.Util (tryError, (<$?>))
import System.Exit (exitFailure, exitSuccess)
import System.FilePath (combine, splitExtensions, takeFileName)
import System.IO (Handle, IOMode (..), SeekMode (..), hFlush, openFile, stdout)
@@ -71,8 +75,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 +89,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 +107,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 +118,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 +164,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)
@@ -153,16 +176,44 @@ processChatCommand = \case
APIGetChatItems _pagination -> pure $ chatCmdError "not implemented"
APISendMessage cType chatId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of
CTDirect -> do
ct@Contact {localDisplayName = c} <- withStore $ \st -> getContact st userId chatId
ci <- sendDirectChatItem userId ct (XMsgNew mc) (CISndMsgContent mc)
setActive $ ActiveC c
pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
ct <- withStore $ \st -> getContact st userId chatId
sendNewMsg user ct (MCSimple mc) mc Nothing
CTGroup -> do
group@(Group gInfo@GroupInfo {localDisplayName = gName, membership} _) <- withStore $ \st -> getGroup st user chatId
group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId
unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
ci <- sendGroupChatItem userId group (XMsgNew mc) (CISndMsgContent mc)
setActive $ ActiveG gName
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
sendNewGroupMsg user group (MCSimple mc) mc Nothing
CTContactRequest -> pure $ chatCmdError "not supported"
APISendMessageQuote cType chatId quotedItemId mc -> withUser $ \user@User {userId} -> withChatLock $ case cType of
CTDirect -> do
(ct, qci) <- withStore $ \st -> (,) <$> getContact st userId chatId <*> getDirectChatItem st userId chatId quotedItemId
case qci of
CChatItem _ ChatItem {meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do
case ciContent of
CISndMsgContent qmc -> send_ CIQDirectSnd True qmc
CIRcvMsgContent qmc -> send_ CIQDirectRcv False qmc
_ -> throwChatError CEInvalidQuote
where
send_ :: CIQDirection 'CTDirect -> Bool -> MsgContent -> m ChatResponse
send_ chatDir sent qmc =
let quotedItem = CIQuote {chatDir, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content = qmc, formattedText}
msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Nothing}
in sendNewMsg user ct (MCQuote QuotedMsg {msgRef, content = qmc} mc) mc (Just quotedItem)
CTGroup -> do
group@(Group GroupInfo {membership} _) <- withStore $ \st -> getGroup st user chatId
unless (memberActive membership) $ throwChatError CEGroupMemberUserRemoved
qci <- withStore $ \st -> getGroupChatItem st user chatId quotedItemId
case qci of
CChatItem _ ChatItem {chatDir, meta = CIMeta {itemTs, itemSharedMsgId}, content = ciContent, formattedText} -> do
case (ciContent, chatDir) of
(CISndMsgContent qmc, _) -> send_ CIQGroupSnd True membership qmc
(CIRcvMsgContent qmc, CIGroupRcv m) -> send_ (CIQGroupRcv $ Just m) False m qmc
_ -> throwChatError CEInvalidQuote
where
send_ :: CIQDirection 'CTGroup -> Bool -> GroupMember -> MsgContent -> m ChatResponse
send_ qd sent GroupMember {memberId} content =
let quotedItem = CIQuote {chatDir = qd, itemId = Just quotedItemId, sharedMsgId = itemSharedMsgId, sentAt = itemTs, content, formattedText}
msgRef = MsgRef {msgId = itemSharedMsgId, sentAt = itemTs, sent, memberId = Just memberId}
in sendNewGroupMsg user group (MCQuote QuotedMsg {msgRef, content} mc) mc (Just quotedItem)
CTContactRequest -> pure $ chatCmdError "not supported"
APIChatRead cType chatId fromToIds -> withChatLock $ case cType of
CTDirect -> withStore (\st -> updateDirectChatItemsRead st chatId fromToIds) $> CRCmdOk
@@ -193,6 +244,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
@@ -237,6 +293,11 @@ processChatCommand = \case
contactId <- withStore $ \st -> getContactIdByName st userId cName
let mc = MCText $ safeDecodeUtf8 msg
processChatCommand $ APISendMessage CTDirect contactId mc
SendMessageQuote cName (AMsgDirection msgDir) quotedMsg msg -> withUser $ \User {userId} -> do
contactId <- withStore $ \st -> getContactIdByName st userId cName
quotedItemId <- withStore $ \st -> getDirectChatItemIdByText st userId contactId msgDir (safeDecodeUtf8 quotedMsg)
let mc = MCText $ safeDecodeUtf8 msg
processChatCommand $ APISendMessageQuote CTDirect contactId quotedItemId mc
NewGroup gProfile -> withUser $ \user -> do
gVar <- asks idsDrg
CRGroupCreated <$> withStore (\st -> createNewGroup st gVar user gProfile)
@@ -312,14 +373,19 @@ processChatCommand = \case
groupId <- withStore $ \st -> getGroupIdByName st user gName
let mc = MCText $ safeDecodeUtf8 msg
processChatCommand $ APISendMessage CTGroup groupId mc
SendFile cName f -> withUser $ \User {userId} -> withChatLock $ do
SendGroupMessageQuote gName cName quotedMsg msg -> withUser $ \user -> do
groupId <- withStore $ \st -> getGroupIdByName st user gName
quotedItemId <- withStore $ \st -> getGroupChatItemIdByText st user groupId cName (safeDecodeUtf8 quotedMsg)
let mc = MCText $ safeDecodeUtf8 msg
processChatCommand $ APISendMessageQuote CTGroup groupId quotedItemId mc
SendFile cName f -> withUser $ \user@User {userId} -> withChatLock $ do
(fileSize, chSize) <- checkSndFile f
contact <- withStore $ \st -> getContactByName st userId cName
(agentConnId, fileConnReq) <- withAgent (`createConnection` SCMInvitation)
let fileInv = FileInvitation {fileName = takeFileName f, fileSize, fileConnReq}
SndFileTransfer {fileId} <- withStore $ \st ->
createSndFileTransfer st userId contact f fileInv agentConnId chSize
ci <- sendDirectChatItem userId contact (XFile fileInv) (CISndFileInvitation fileId f)
ci <- sendDirectChatItem user contact (XFile fileInv) (CISndFileInvitation fileId f) Nothing
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
setActive $ ActiveC cName
pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat contact) ci
@@ -337,10 +403,9 @@ processChatCommand = \case
traverse (\conn -> sendDirectMessage conn (XFile fileInv) (GroupId groupId)) $ memberConn m
setActive $ ActiveG gName
-- this is a hack as we have multiple direct messages instead of one per group
let ciContent = CISndFileInvitation fileId f
createdAt <- liftIO getCurrentTime
let ci = mkNewChatItem ciContent 0 createdAt createdAt
cItem@ChatItem {meta = CIMeta {itemId}} <- saveChatItem userId (CDGroupSnd gInfo) ci
let msg = SndMessage {msgId = 0, sharedMsgId = SharedMsgId "", msgBody = ""}
ciContent = CISndFileInvitation fileId f
cItem@ChatItem {meta = CIMeta {itemId}} <- saveSndChatItem user (CDGroupSnd gInfo) msg ciContent Nothing
withStore $ \st -> updateFileTransferChatItemId st fileId itemId
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) cItem
ReceiveFile fileId filePath_ -> withUser $ \User {userId} -> do
@@ -367,17 +432,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}
updateProfile user p
QuitChat -> liftIO exitSuccess
ShowVersion -> pure $ CRVersionInfo versionNumber
where
@@ -408,6 +468,14 @@ processChatCommand = \case
connId <- withAgent $ \a -> joinConnection a cReq $ directMessage (XContact profile $ Just xContactId)
withStore $ \st -> createConnReqConnection st userId connId cReqHash xContactId
pure CRSentInvitation
sendNewMsg user ct@Contact {localDisplayName = c} msgContainer mc quotedItem = do
ci <- sendDirectChatItem user ct (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem
setActive $ ActiveC c
pure . CRNewChatItem $ AChatItem SCTDirect SMDSnd (DirectChat ct) ci
sendNewGroupMsg user g@(Group gInfo@GroupInfo {localDisplayName = gName} _) msgContainer mc quotedItem = do
ci <- sendGroupChatItem user g (XMsgNew msgContainer) (CISndMsgContent mc) quotedItem
setActive $ ActiveG gName
pure . CRNewChatItem $ AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci
contactMember :: Contact -> [GroupMember] -> Maybe GroupMember
contactMember Contact {contactId} =
find $ \GroupMember {memberContactId = cId, memberStatus = s} ->
@@ -416,6 +484,21 @@ 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 ->
let s = connStatus $ activeConn (ct :: Contact)
in when (s == ConnReady || s == ConnSndReady) $
void (sendDirectContactMessage ct $ XInfo p') `catchError` (toView . CRChatError)
pure $ CRUserProfileUpdated p p'
getRcvFilePath :: Int64 -> Maybe FilePath -> String -> m FilePath
getRcvFilePath fileId filePath fileName = case filePath of
Nothing -> do
@@ -502,7 +585,7 @@ subscribeUserConnections user@User {userId} = do
ms <- pooledForConcurrentlyN n connectedMembers $ \(m@GroupMember {localDisplayName = c}, cId) ->
(m,) <$> ((subscribe cId $> Nothing) `catchError` (\e -> when ce (toView $ CRMemberSubError g c e) $> Just e))
toView $ CRGroupSubscribed g
pure $ mapMaybe (\(m, e) -> maybe Nothing (Just . MemberSubError m) e) ms
pure $ mapMaybe (\(m, e) -> (Just . MemberSubError m) =<< e) ms
subscribeFiles n = do
sndFileTransfers <- withStore (`getLiveSndFileTransfers` user)
pooledForConcurrentlyN_ n sndFileTransfers $ \sft -> subscribeSndFile sft
@@ -528,7 +611,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)
@@ -581,7 +666,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
INFO connInfo ->
saveConnInfo conn connInfo
MSG meta msgBody -> do
_ <- saveRcvMSG conn meta msgBody (ConnectionId connId)
_ <- saveRcvMSG conn (ConnectionId connId) meta msgBody
withAckMessage agentConnId meta $ pure ()
ackMsgDeliveryEvent conn meta
SENT msgId ->
@@ -592,13 +677,13 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
ERR _ -> pure ()
-- TODO add debugging output
_ -> pure ()
Just ct@Contact {localDisplayName = c} -> case agentMsg of
Just ct@Contact {localDisplayName = c, contactId} -> case agentMsg of
MSG msgMeta msgBody -> do
(msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody (ConnectionId connId)
msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (ConnectionId connId) msgMeta msgBody
withAckMessage agentConnId msgMeta $
case chatMsgEvent of
XMsgNew mc -> newContentMessage ct mc msgId msgMeta
XFile fInv -> processFileInvitation ct fInv msgId msgMeta
XMsgNew mc -> newContentMessage ct mc msg msgMeta
XFile fInv -> processFileInvitation ct fInv msg msgMeta
XInfo p -> xInfo ct p
XGrpInv gInv -> processGroupInvitation ct gInv
XInfoProbe probe -> xInfoProbe ct probe
@@ -643,7 +728,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
case chatItemId_ of
Nothing -> pure ()
Just chatItemId -> do
chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId CISSndSent
chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId CISSndSent
toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
END -> do
toView $ CRContactAnotherClient ct
@@ -662,7 +747,7 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
case chatItemId_ of
Nothing -> pure ()
Just chatItemId -> do
chatItem <- withStore $ \st -> updateDirectChatItem st chatItemId (agentErrToItemStatus err)
chatItem <- withStore $ \st -> updateDirectChatItem st userId contactId chatItemId (agentErrToItemStatus err)
toView $ CRChatItemUpdated (AChatItem SCTDirect SMDSnd (DirectChat ct) chatItem)
ERR _ -> pure ()
-- TODO add debugging output
@@ -733,11 +818,11 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
notifyMemberConnected gInfo m
when (memberCategory m == GCPreMember) $ probeMatchingContacts ct
MSG msgMeta msgBody -> do
(msgId, chatMsgEvent) <- saveRcvMSG conn msgMeta msgBody (GroupId groupId)
msg@RcvMessage {chatMsgEvent} <- saveRcvMSG conn (GroupId groupId) msgMeta msgBody
withAckMessage agentConnId msgMeta $
case chatMsgEvent of
XMsgNew mc -> newGroupContentMessage gInfo m mc msgId msgMeta
XFile fInv -> processGroupFileInvitation gInfo m fInv msgId msgMeta
XMsgNew mc -> newGroupContentMessage gInfo m mc msg msgMeta
XFile fInv -> processGroupFileInvitation gInfo m fInv msg msgMeta
XGrpMemNew memInfo -> xGrpMemNew gInfo m memInfo
XGrpMemIntro memInfo -> xGrpMemIntro conn gInfo m memInfo
XGrpMemInv memId introInv -> xGrpMemInv gInfo m memId introInv
@@ -906,39 +991,41 @@ processAgentMessage (Just user@User {userId, profile}) agentConnId agentMessage
messageError :: Text -> m ()
messageError = toView . CRMessageError "error"
newContentMessage :: Contact -> MsgContent -> MessageId -> MsgMeta -> m ()
newContentMessage ct@Contact {localDisplayName = c} mc msgId msgMeta = do
ci <- saveRcvChatItem userId (CDDirectRcv ct) msgId msgMeta (CIRcvMsgContent mc)
newContentMessage :: Contact -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newContentMessage ct@Contact {localDisplayName = c} mc msg msgMeta = do
let content = mcContent mc
ci@ChatItem {formattedText} <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvMsgContent content)
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
showToast (c <> "> ") $ msgContentText mc
showMsgToast (c <> "> ") content formattedText
setActive $ ActiveC c
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContent -> MessageId -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msgId msgMeta = do
ci <- saveRcvChatItem userId (CDGroupRcv gInfo m) msgId msgMeta (CIRcvMsgContent mc)
newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m ()
newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg msgMeta = do
let content = mcContent mc
ci@ChatItem {formattedText} <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvMsgContent content)
groupMsgToView gInfo ci msgMeta
let g = groupName' gInfo
showToast ("#" <> g <> " " <> c <> "> ") $ msgContentText mc
showMsgToast ("#" <> g <> " " <> c <> "> ") content formattedText
setActive $ ActiveG g
processFileInvitation :: Contact -> FileInvitation -> MessageId -> MsgMeta -> m ()
processFileInvitation ct@Contact {localDisplayName = c} fInv msgId msgMeta = do
processFileInvitation :: Contact -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
processFileInvitation ct@Contact {localDisplayName = c} fInv msg msgMeta = do
-- TODO chunk size has to be sent as part of invitation
chSize <- asks $ fileChunkSize . config
ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvFileTransfer st userId ct fInv chSize
ci <- saveRcvChatItem userId (CDDirectRcv ct) msgId msgMeta (CIRcvFileInvitation ft)
ci <- saveRcvChatItem user (CDDirectRcv ct) msg msgMeta (CIRcvFileInvitation ft)
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
toView . CRNewChatItem $ AChatItem SCTDirect SMDRcv (DirectChat ct) ci
checkIntegrity msgMeta $ toView . CRMsgIntegrityError
showToast (c <> "> ") "wants to send a file"
setActive $ ActiveC c
processGroupFileInvitation :: GroupInfo -> GroupMember -> FileInvitation -> MessageId -> MsgMeta -> m ()
processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msgId msgMeta = do
processGroupFileInvitation :: GroupInfo -> GroupMember -> FileInvitation -> RcvMessage -> MsgMeta -> m ()
processGroupFileInvitation gInfo m@GroupMember {localDisplayName = c} fInv msg msgMeta = do
chSize <- asks $ fileChunkSize . config
ft@RcvFileTransfer {fileId} <- withStore $ \st -> createRcvGroupFileTransfer st userId m fInv chSize
ci <- saveRcvChatItem userId (CDGroupRcv gInfo m) msgId msgMeta (CIRcvFileInvitation ft)
ci <- saveRcvChatItem user (CDGroupRcv gInfo m) msg msgMeta (CIRcvFileInvitation ft)
withStore $ \st -> updateFileTransferChatItemId st fileId $ chatItemId' ci
groupMsgToView gInfo ci msgMeta
let g = groupName' gInfo
@@ -1215,27 +1302,27 @@ deleteMemberConnection m@GroupMember {activeConn} = do
-- withStore $ \st -> deleteGroupMemberConnection st userId m
forM_ activeConn $ \conn -> withStore $ \st -> updateConnectionStatus st conn ConnDeleted
sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m MessageId
sendDirectContactMessage :: ChatMonad m => Contact -> ChatMsgEvent -> m SndMessage
sendDirectContactMessage ct@Contact {activeConn = conn@Connection {connId, connStatus}} chatMsgEvent = do
if connStatus == ConnReady || connStatus == ConnSndReady
then sendDirectMessage conn chatMsgEvent (ConnectionId connId)
else throwChatError $ CEContactNotReady ct
sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> ConnOrGroupId -> m MessageId
sendDirectMessage :: ChatMonad m => Connection -> ChatMsgEvent -> ConnOrGroupId -> m SndMessage
sendDirectMessage conn chatMsgEvent connOrGroupId = do
(msgId, msgBody) <- createSndMessage chatMsgEvent connOrGroupId
msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent connOrGroupId
deliverMessage conn msgBody msgId
pure msgId
pure msg
createSndMessage :: ChatMonad m => ChatMsgEvent -> ConnOrGroupId -> m (MessageId, MsgBody)
createSndMessage :: ChatMonad m => ChatMsgEvent -> ConnOrGroupId -> m SndMessage
createSndMessage chatMsgEvent connOrGroupId = do
let msgBody = directMessage chatMsgEvent
newMsg = NewMessage {direction = MDSnd, cmEventTag = toCMEventTag chatMsgEvent, msgBody}
msgId <- withStore $ \st -> createNewMessage st newMsg connOrGroupId
pure (msgId, msgBody)
gVar <- asks idsDrg
withStore $ \st -> createNewSndMessage st gVar connOrGroupId $ \sharedMsgId ->
let msgBody = strEncode ChatMessage {msgId = Just sharedMsgId, chatMsgEvent}
in NewMessage {chatMsgEvent, msgBody}
directMessage :: ChatMsgEvent -> ByteString
directMessage chatMsgEvent = strEncode ChatMessage {chatMsgEvent}
directMessage chatMsgEvent = strEncode ChatMessage {msgId = Nothing, chatMsgEvent}
deliverMessage :: ChatMonad m => Connection -> MsgBody -> MessageId -> m ()
deliverMessage conn@Connection {connId} msgBody msgId = do
@@ -1243,18 +1330,18 @@ deliverMessage conn@Connection {connId} msgBody msgId = do
let sndMsgDelivery = SndMsgDelivery {connId, agentMsgId}
withStore $ \st -> createSndMsgDelivery st sndMsgDelivery msgId
sendGroupMessage :: ChatMonad m => GroupInfo -> [GroupMember] -> ChatMsgEvent -> m MessageId
sendGroupMessage :: ChatMonad m => GroupInfo -> [GroupMember] -> ChatMsgEvent -> m SndMessage
sendGroupMessage GroupInfo {groupId} members chatMsgEvent =
sendGroupMessage' members chatMsgEvent groupId Nothing $ pure ()
sendXGrpMemInv :: ChatMonad m => GroupInfo -> GroupMember -> ChatMsgEvent -> Int64 -> m MessageId
sendXGrpMemInv :: ChatMonad m => GroupInfo -> GroupMember -> ChatMsgEvent -> Int64 -> m SndMessage
sendXGrpMemInv GroupInfo {groupId} reMember chatMsgEvent introId =
sendGroupMessage' [reMember] chatMsgEvent groupId (Just introId) $
withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded)
sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Int64 -> Maybe Int64 -> m () -> m MessageId
sendGroupMessage' :: ChatMonad m => [GroupMember] -> ChatMsgEvent -> Int64 -> Maybe Int64 -> m () -> m SndMessage
sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do
(msgId, msgBody) <- createSndMessage chatMsgEvent (GroupId groupId)
msg@SndMessage {msgId, msgBody} <- createSndMessage chatMsgEvent (GroupId groupId)
-- TODO collect failed deliveries into a single error
forM_ (filter memberCurrent members) $ \m@GroupMember {groupMemberId} ->
case memberConn m of
@@ -1263,7 +1350,7 @@ sendGroupMessage' members chatMsgEvent groupId introId_ postDeliver = do
if not (connStatus == ConnSndReady || connStatus == ConnReady)
then unless (connStatus == ConnDeleted) $ withStore (\st -> createPendingGroupMessage st groupMemberId msgId introId_)
else (deliverMessage conn msgBody msgId >> postDeliver) `catchError` const (pure ())
pure msgId
pure msg
sendPendingGroupMessages :: ChatMonad m => GroupMember -> Connection -> m ()
sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do
@@ -1276,54 +1363,42 @@ sendPendingGroupMessages GroupMember {groupMemberId, localDisplayName} conn = do
Nothing -> throwChatError $ CEGroupMemberIntroNotFound localDisplayName
Just introId -> withStore (\st -> updateIntroStatus st introId GMIntroInvForwarded)
saveRcvMSG :: ChatMonad m => Connection -> MsgMeta -> MsgBody -> ConnOrGroupId -> m (MessageId, ChatMsgEvent)
saveRcvMSG Connection {connId} agentMsgMeta msgBody connOrGroupId = do
ChatMessage {chatMsgEvent} <- liftEither $ parseChatMessage msgBody
saveRcvMSG :: ChatMonad m => Connection -> ConnOrGroupId -> MsgMeta -> MsgBody -> m RcvMessage
saveRcvMSG Connection {connId} connOrGroupId agentMsgMeta msgBody = do
ChatMessage {msgId = sharedMsgId_, chatMsgEvent} <- liftEither $ parseChatMessage msgBody
let agentMsgId = fst $ recipient agentMsgMeta
cmEventTag = toCMEventTag chatMsgEvent
newMsg = NewMessage {direction = MDRcv, cmEventTag, msgBody}
newMsg = NewMessage {chatMsgEvent, msgBody}
rcvMsgDelivery = RcvMsgDelivery {connId, agentMsgId, agentMsgMeta}
msgId <- withStore $ \st -> createNewMessageAndRcvMsgDelivery st newMsg connOrGroupId rcvMsgDelivery
pure (msgId, chatMsgEvent)
withStore $ \st -> createNewMessageAndRcvMsgDelivery st connOrGroupId newMsg sharedMsgId_ rcvMsgDelivery
sendDirectChatItem :: ChatMonad m => UserId -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTDirect 'MDSnd)
sendDirectChatItem userId ct chatMsgEvent ciContent = do
msgId <- sendDirectContactMessage ct chatMsgEvent
saveSndChatItem userId (CDDirectSnd ct) msgId ciContent
sendDirectChatItem :: ChatMonad m => User -> Contact -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTDirect) -> m (ChatItem 'CTDirect 'MDSnd)
sendDirectChatItem user ct chatMsgEvent ciContent quotedItem = do
msg <- sendDirectContactMessage ct chatMsgEvent
saveSndChatItem user (CDDirectSnd ct) msg ciContent quotedItem
sendGroupChatItem :: ChatMonad m => UserId -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> m (ChatItem 'CTGroup 'MDSnd)
sendGroupChatItem userId (Group g ms) chatMsgEvent ciContent = do
msgId <- sendGroupMessage g ms chatMsgEvent
saveSndChatItem userId (CDGroupSnd g) msgId ciContent
sendGroupChatItem :: ChatMonad m => User -> Group -> ChatMsgEvent -> CIContent 'MDSnd -> Maybe (CIQuote 'CTGroup) -> m (ChatItem 'CTGroup 'MDSnd)
sendGroupChatItem user (Group g ms) chatMsgEvent ciContent quotedItem = do
msg <- sendGroupMessage g ms chatMsgEvent
saveSndChatItem user (CDGroupSnd g) msg ciContent quotedItem
saveSndChatItem :: ChatMonad m => UserId -> ChatDirection c 'MDSnd -> MessageId -> CIContent 'MDSnd -> m (ChatItem c 'MDSnd)
saveSndChatItem userId cd msgId ciContent = do
saveSndChatItem :: ChatMonad m => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> m (ChatItem c 'MDSnd)
saveSndChatItem user cd msg@SndMessage {sharedMsgId} content quotedItem = do
createdAt <- liftIO getCurrentTime
saveChatItem userId cd $ mkNewChatItem ciContent msgId createdAt createdAt
ciId <- withStore $ \st -> createNewSndChatItem st user cd msg content quotedItem createdAt
liftIO $ mkChatItem cd ciId content quotedItem (Just sharedMsgId) createdAt createdAt
saveRcvChatItem :: ChatMonad m => UserId -> ChatDirection c 'MDRcv -> MessageId -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv)
saveRcvChatItem userId cd msgId MsgMeta {broker = (_, brokerTs)} ciContent = do
saveRcvChatItem :: ChatMonad m => User -> ChatDirection c 'MDRcv -> RcvMessage -> MsgMeta -> CIContent 'MDRcv -> m (ChatItem c 'MDRcv)
saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} MsgMeta {broker = (_, brokerTs)} content = do
createdAt <- liftIO getCurrentTime
saveChatItem userId cd $ mkNewChatItem ciContent msgId brokerTs createdAt
(ciId, quotedItem) <- withStore $ \st -> createNewRcvChatItem st user cd msg content brokerTs createdAt -- createNewChatItem st user cd $ mkNewChatItem content msg brokerTs createdAt
liftIO $ mkChatItem cd ciId content quotedItem sharedMsgId_ brokerTs createdAt
saveChatItem :: (ChatMonad m, MsgDirectionI d) => UserId -> ChatDirection c d -> NewChatItem d -> m (ChatItem c d)
saveChatItem userId cd ci@NewChatItem {itemContent, itemTs, itemText, createdAt} = do
tz <- liftIO getCurrentTimeZone
ciId <- withStore $ \st -> createNewChatItem st userId cd ci
let ciMeta = mkCIMeta ciId itemText ciStatusNew tz itemTs createdAt
pure $ ChatItem (toCIDirection cd) ciMeta itemContent $ parseMaybeMarkdownList itemText
mkNewChatItem :: forall d. MsgDirectionI d => CIContent d -> MessageId -> UTCTime -> UTCTime -> NewChatItem d
mkNewChatItem itemContent msgId itemTs createdAt =
NewChatItem
{ createdByMsgId = if msgId == 0 then Nothing else Just msgId,
itemSent = msgDirection @d,
itemTs,
itemContent,
itemText = ciContentToText itemContent,
itemStatus = ciStatusNew,
createdAt
}
mkChatItem :: MsgDirectionI d => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIQuote c) -> Maybe SharedMsgId -> ChatItemTs -> UTCTime -> IO (ChatItem c d)
mkChatItem cd ciId content quotedItem sharedMsgId itemTs createdAt = do
tz <- getCurrentTimeZone
let itemText = ciContentToText content
meta = mkCIMeta ciId itemText ciStatusNew sharedMsgId tz itemTs createdAt
pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem}
allowAgentConnection :: ChatMonad m => Connection -> ConfirmationId -> ChatMsgEvent -> m ()
allowAgentConnection conn confId msg = do
@@ -1351,7 +1426,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
@@ -1388,6 +1463,13 @@ getCreateActiveUser st = do
getWithPrompt :: String -> IO String
getWithPrompt s = putStr (s <> ": ") >> hFlush stdout >> getLine
showMsgToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> MsgContent -> Maybe MarkdownList -> m ()
showMsgToast from mc md_ = showToast from $ maybe (msgContentText mc) (mconcat . map hideSecret) md_
where
hideSecret :: FormattedText -> Text
hideSecret FormattedText {format = Just Secret} = "..."
hideSecret FormattedText {text} = text
showToast :: (MonadUnliftIO m, MonadReader ChatController m) => Text -> Text -> m ()
showToast title text = atomically . (`writeTBQueue` Notification {title, text}) =<< asks notifyQ
@@ -1421,6 +1503,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
@@ -1432,13 +1516,18 @@ chatCommandP =
<|> "/_get chat " *> (APIGetChat <$> chatTypeP <*> A.decimal <* A.space <*> chatPaginationP)
<|> "/_get items count=" *> (APIGetChatItems <$> A.decimal)
<|> "/_send " *> (APISendMessage <$> chatTypeP <*> A.decimal <* A.space <*> msgContentP)
<|> "/_send_quote " *> (APISendMessageQuote <$> chatTypeP <*> A.decimal <* A.space <*> A.decimal <* A.space <*> msgContentP)
<|> "/_read chat " *> (APIChatRead <$> chatTypeP <*> A.decimal <* A.space <*> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))
<|> "/_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
<|> ("/help replies" <|> "/hr") $> ChatHelp HSQuotes
<|> ("/help" <|> "/h") $> ChatHelp HSMain
<|> ("/group #" <|> "/group " <|> "/g #" <|> "/g ") *> (NewGroup <$> groupProfile)
<|> ("/add #" <|> "/add " <|> "/a #" <|> "/a ") *> (AddMember <$> displayName <* A.space <*> displayName <*> memberRole)
@@ -1449,11 +1538,15 @@ chatCommandP =
<|> ("/members #" <|> "/members " <|> "/ms #" <|> "/ms ") *> (ListMembers <$> displayName)
<|> ("/groups" <|> "/gs") $> ListGroups
<|> A.char '#' *> (SendGroupMessage <$> displayName <* A.space <*> A.takeByteString)
<|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <*> pure Nothing <*> quotedMsg <*> A.takeByteString)
<|> (">#" <|> "> #") *> (SendGroupMessageQuote <$> displayName <* A.space <* optional (A.char '@') <*> (Just <$> displayName) <* A.space <*> quotedMsg <*> A.takeByteString)
<|> ("/contacts" <|> "/cs") $> ListContacts
<|> ("/connect " <|> "/c ") *> (Connect <$> ((Just <$> strP) <|> A.takeByteString $> Nothing))
<|> ("/connect" <|> "/c") $> AddContact
<|> ("/delete @" <|> "/delete " <|> "/d @" <|> "/d ") *> (DeleteContact <$> displayName)
<|> A.char '@' *> (SendMessage <$> displayName <* A.space <*> A.takeByteString)
<|> (">@" <|> "> @") *> sendMsgQuote (AMsgDirection SMDRcv)
<|> (">>@" <|> ">> @") *> sendMsgQuote (AMsgDirection SMDSnd)
<|> ("/file #" <|> "/f #") *> (SendGroupFile <$> displayName <* A.space <*> filePath)
<|> ("/file @" <|> "/file " <|> "/f @" <|> "/f ") *> (SendFile <$> displayName <* A.space <*> filePath)
<|> ("/freceive " <|> "/fr ") *> (ReceiveFile <$> A.decimal <*> optional (A.space *> filePath))
@@ -1468,28 +1561,39 @@ chatCommandP =
<|> ("/reject @" <|> "/reject " <|> "/rc @" <|> "/rc ") *> (RejectContact <$> displayName)
<|> ("/markdown" <|> "/m") $> ChatHelp HSMarkdown
<|> ("/welcome" <|> "/w") $> Welcome
<|> ("/profile " <|> "/p ") *> (UpdateProfile <$> userProfile)
<|> "/profile_image " *> (UpdateProfileImage . Just . ProfileImage <$> imageP)
<|> "/profile_image" $> UpdateProfileImage Nothing
<|> ("/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)
<|> (CPAfter <$ "after=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
<|> (CPBefore <$ "before=" <*> A.decimal <* A.space <* "count=" <*> A.decimal)
msgContentP = "text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString)
msgContentP =
"text " *> (MCText . safeDecodeUtf8 <$> A.takeByteString)
<|> "json " *> (J.eitherDecodeStrict' <$?> A.takeByteString)
displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' '))
sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> A.takeByteString
quotedMsg = A.char '(' *> A.takeTill (== ')') <* A.char ')' <* optional A.space
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
+25 -3
View File
@@ -20,8 +20,10 @@ import Data.ByteString.Char8 (ByteString)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import Data.Text (Text)
import Data.Version (showVersion)
import GHC.Generics (Generic)
import Numeric.Natural
import qualified Paths_simplex_chat as SC
import Simplex.Chat.Messages
import Simplex.Chat.Protocol
import Simplex.Chat.Store (StoreError)
@@ -36,7 +38,7 @@ import System.IO (Handle)
import UnliftIO.STM
versionNumber :: String
versionNumber = "1.3.0"
versionNumber = showVersion SC.version
versionStr :: String
versionStr = "SimpleX Chat v" <> versionNumber
@@ -76,7 +78,7 @@ data ChatController = ChatController
config :: ChatConfig
}
data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown
data HelpSection = HSMain | HSFiles | HSGroups | HSMyAddress | HSMarkdown | HSQuotes
deriving (Show, Generic)
instance ToJSON HelpSection where
@@ -91,10 +93,13 @@ data ChatCommand
| APIGetChat ChatType Int64 ChatPagination
| APIGetChatItems Int
| APISendMessage ChatType Int64 MsgContent
| APISendMessageQuote ChatType Int64 ChatItemId MsgContent
| APIChatRead ChatType Int64 (ChatItemId, ChatItemId)
| APIDeleteChat ChatType Int64
| APIAcceptContact Int64
| APIRejectContact Int64
| GetUserSMPServers
| SetUserSMPServers [SMPServer]
| ChatHelp HelpSection
| Welcome
| AddContact
@@ -109,6 +114,7 @@ data ChatCommand
| AcceptContact ContactName
| RejectContact ContactName
| SendMessage ContactName ByteString
| SendMessageQuote {contactName :: ContactName, msgDir :: AMsgDirection, quotedMsg :: ByteString, message :: ByteString}
| NewGroup GroupProfile
| AddMember GroupName ContactName GroupMemberRole
| JoinGroup GroupName
@@ -119,13 +125,15 @@ data ChatCommand
| ListMembers GroupName
| ListGroups
| SendGroupMessage GroupName ByteString
| SendGroupMessageQuote {groupName :: GroupName, contactName_ :: Maybe ContactName, quotedMsg :: ByteString, message :: ByteString}
| SendFile ContactName FilePath
| SendGroupFile GroupName FilePath
| ReceiveFile FileTransferId (Maybe FilePath)
| CancelFile FileTransferId
| FileStatus FileTransferId
| ShowProfile
| UpdateProfile Profile
| UpdateProfile ContactName Text
| UpdateProfileImage (Maybe ProfileImage)
| QuitChat
| ShowVersion
deriving (Show)
@@ -133,8 +141,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 +214,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 +247,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}
@@ -273,6 +294,7 @@ data ChatErrorType
| CEFileSend {fileId :: FileTransferId, agentError :: AgentErrorType}
| CEFileRcvChunk {message :: String}
| CEFileInternal {message :: String}
| CEInvalidQuote
| CEAgentVersion
| CECommandError {message :: String}
deriving (Show, Exception, Generic)
+16 -6
View File
@@ -7,6 +7,7 @@ module Simplex.Chat.Help
filesHelpInfo,
groupsHelpInfo,
myAddressHelpInfo,
quotesHelpInfo,
markdownInfo,
)
where
@@ -44,11 +45,7 @@ chatWelcome user =
"Welcome " <> green userName <> "!",
"Thank you for installing SimpleX Chat!",
"",
"We have a couple of groups that you can join to play with SimpleX Chat:",
highlight "#termux" <> " (Android Termux 📱) - chatting about using SimpleX Chat on Android devices",
highlight "#music" <> " (Music 🎸) - favorite music of our team and users",
"",
"Connect to SimpleX Chat team to be added to these groups - type " <> highlight "/simplex",
"Connect to SimpleX Chat lead developer for any questions - just type " <> highlight "/simplex",
"",
"Follow our updates:",
"> Reddit: https://www.reddit.com/r/SimpleXChat/",
@@ -86,10 +83,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, replies, 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",
@@ -145,6 +143,18 @@ myAddressHelpInfo =
"The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"]
]
quotesHelpInfo :: [StyledString]
quotesHelpInfo =
map
styleMarkdown
[ green "Sending replies to messages",
"To quote a message that starts with \"hi\":",
indent <> highlight "> @alice (hi) <msg> " <> " - to reply to alice's most recent message",
indent <> highlight ">> @alice (hi) <msg> " <> " - to quote user's most recent message to alice",
indent <> highlight "> #team (hi) <msg> " <> " - to quote most recent message in the group from any member",
indent <> highlight "> #team @alice (hi) <msg>" <> " - to quote alice's most recent message in the group #team"
]
markdownInfo :: [StyledString]
markdownInfo =
map
+78 -11
View File
@@ -78,7 +78,8 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem
{ chatDir :: CIDirection c d,
meta :: CIMeta d,
content :: CIContent d,
formattedText :: Maybe [FormattedText]
formattedText :: Maybe MarkdownList,
quotedItem :: Maybe (CIQuote c)
}
deriving (Show, Generic)
@@ -101,9 +102,6 @@ data JSONCIDirection
| JCIGroupRcv {groupMember :: GroupMember}
deriving (Generic, Show)
instance FromJSON JSONCIDirection where
parseJSON = J.genericParseJSON . sumTypeJSON $ dropPrefix "JCI"
instance ToJSON JSONCIDirection where
toJSON = J.genericToJSON . sumTypeJSON $ dropPrefix "JCI"
toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "JCI"
@@ -150,6 +148,8 @@ data NewChatItem d = NewChatItem
itemContent :: CIContent d,
itemText :: Text,
itemStatus :: CIStatus d,
itemSharedMsgId :: Maybe SharedMsgId,
itemQuotedMsg :: Maybe QuotedMsg,
createdAt :: UTCTime
}
deriving (Show)
@@ -185,7 +185,7 @@ instance ToJSON ChatStats where
toEncoding = J.genericToEncoding J.defaultOptions
-- | type to show a mix of messages from multiple chats
data AChatItem = forall c d. AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d)
data AChatItem = forall c d. MsgDirectionI d => AChatItem (SChatType c) (SMsgDirection d) (ChatInfo c) (ChatItem c d)
deriving instance Show AChatItem
@@ -205,18 +205,60 @@ data CIMeta (d :: MsgDirection) = CIMeta
itemTs :: ChatItemTs,
itemText :: Text,
itemStatus :: CIStatus d,
itemSharedMsgId :: Maybe SharedMsgId,
localItemTs :: ZonedTime,
createdAt :: UTCTime
}
deriving (Show, Generic)
mkCIMeta :: ChatItemId -> Text -> CIStatus d -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d
mkCIMeta itemId itemText itemStatus tz itemTs createdAt =
mkCIMeta :: ChatItemId -> Text -> CIStatus d -> Maybe SharedMsgId -> TimeZone -> ChatItemTs -> UTCTime -> CIMeta d
mkCIMeta itemId itemText itemStatus itemSharedMsgId tz itemTs createdAt =
let localItemTs = utcToZonedTime tz itemTs
in CIMeta {itemId, itemTs, itemText, itemStatus, localItemTs, createdAt}
in CIMeta {itemId, itemTs, itemText, itemStatus, itemSharedMsgId, localItemTs, createdAt}
instance ToJSON (CIMeta d) where toEncoding = J.genericToEncoding J.defaultOptions
data CIQuote (c :: ChatType) = CIQuote
{ chatDir :: CIQDirection c,
itemId :: Maybe ChatItemId, -- Nothing in case MsgRef references the item the user did not receive yet
sharedMsgId :: Maybe SharedMsgId, -- Nothing for the messages from the old clients
sentAt :: UTCTime,
content :: MsgContent,
formattedText :: Maybe MarkdownList
}
deriving (Show, Generic)
instance ToJSON (CIQuote c) where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data CIQDirection (c :: ChatType) where
CIQDirectSnd :: CIQDirection 'CTDirect
CIQDirectRcv :: CIQDirection 'CTDirect
CIQGroupSnd :: CIQDirection 'CTGroup
CIQGroupRcv :: Maybe GroupMember -> CIQDirection 'CTGroup -- member can be Nothing in case MsgRef has memberId that the user is not notified about yet
deriving instance Show (CIQDirection c)
instance ToJSON (CIQDirection c) where
toJSON = J.toJSON . jsonCIQDirection
toEncoding = J.toEncoding . jsonCIQDirection
jsonCIQDirection :: CIQDirection c -> Maybe JSONCIDirection
jsonCIQDirection = \case
CIQDirectSnd -> Just JCIDirectSnd
CIQDirectRcv -> Just JCIDirectRcv
CIQGroupSnd -> Just JCIGroupSnd
CIQGroupRcv (Just m) -> Just $ JCIGroupRcv m
CIQGroupRcv Nothing -> Nothing
quoteMsgDirection :: CIQDirection c -> MsgDirection
quoteMsgDirection = \case
CIQDirectSnd -> MDSnd
CIQDirectRcv -> MDRcv
CIQGroupSnd -> MDSnd
CIQGroupRcv _ -> MDRcv
data CIStatus (d :: MsgDirection) where
CISSndNew :: CIStatus 'MDSnd
CISSndSent :: CIStatus 'MDSnd
@@ -242,6 +284,8 @@ instance FromField ACIStatus where fromField = fromTextField_ $ eitherToMaybe .
data ACIStatus = forall d. MsgDirectionI d => ACIStatus (SMsgDirection d) (CIStatus d)
deriving instance Show ACIStatus
instance MsgDirectionI d => StrEncoding (CIStatus d) where
strEncode = \case
CISSndNew -> "snd_new"
@@ -322,6 +366,8 @@ instance ToJSON (CIContent d) where
data ACIContent = forall d. ACIContent (SMsgDirection d) (CIContent d)
deriving instance Show ACIContent
-- platform specific
instance FromJSON ACIContent where
parseJSON = fmap aciContentJSON . J.parseJSON
@@ -407,12 +453,24 @@ instance ChatTypeI 'CTDirect where chatType = SCTDirect
instance ChatTypeI 'CTGroup where chatType = SCTGroup
data NewMessage = NewMessage
{ direction :: MsgDirection,
cmEventTag :: CMEventTag,
{ chatMsgEvent :: ChatMsgEvent,
msgBody :: MsgBody
}
deriving (Show)
data SndMessage = SndMessage
{ msgId :: MessageId,
sharedMsgId :: SharedMsgId,
msgBody :: MsgBody
}
data RcvMessage = RcvMessage
{ msgId :: MessageId,
chatMsgEvent :: ChatMsgEvent,
sharedMsgId_ :: Maybe SharedMsgId,
msgBody :: MsgBody
}
data PendingGroupMessage = PendingGroupMessage
{ msgId :: MessageId,
cmEventTag :: CMEventTag,
@@ -425,7 +483,7 @@ type MessageId = Int64
data ConnOrGroupId = ConnectionId Int64 | GroupId Int64
data MsgDirection = MDRcv | MDSnd
deriving (Show, Generic)
deriving (Eq, Show, Generic)
instance FromJSON MsgDirection where
parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MD"
@@ -449,11 +507,20 @@ instance TestEquality SMsgDirection where
instance ToField (SMsgDirection d) where toField = toField . msgDirectionInt . toMsgDirection
data AMsgDirection = forall d. MsgDirectionI d => AMsgDirection (SMsgDirection d)
deriving instance Show AMsgDirection
toMsgDirection :: SMsgDirection d -> MsgDirection
toMsgDirection = \case
SMDRcv -> MDRcv
SMDSnd -> MDSnd
fromMsgDirection :: MsgDirection -> AMsgDirection
fromMsgDirection = \case
MDRcv -> AMsgDirection SMDRcv
MDSnd -> AMsgDirection SMDSnd
class MsgDirectionI (d :: MsgDirection) where
msgDirection :: SMsgDirection d
@@ -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;
|]
@@ -0,0 +1,24 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Migrations.M20220304_msg_quotes where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20220304_msg_quotes :: Query
m20220304_msg_quotes =
[sql|
ALTER TABLE messages ADD COLUMN shared_msg_id BLOB;
ALTER TABLE messages ADD COLUMN shared_msg_id_user INTEGER; -- 1 for user messages, NULL for received messages
CREATE INDEX idx_messages_shared_msg_id ON messages (shared_msg_id);
CREATE UNIQUE INDEX idx_messages_direct_shared_msg_id ON messages (connection_id, shared_msg_id_user, shared_msg_id);
CREATE UNIQUE INDEX idx_messages_group_shared_msg_id ON messages (group_id, shared_msg_id_user, shared_msg_id);
ALTER TABLE chat_items ADD COLUMN shared_msg_id BLOB;
ALTER TABLE chat_items ADD COLUMN quoted_shared_msg_id BLOB; -- from MessageRef in QuotedMsg
ALTER TABLE chat_items ADD COLUMN quoted_sent_at TEXT; -- from MessageRef in QuotedMsg
ALTER TABLE chat_items ADD COLUMN quoted_content TEXT; -- from MsgContent in QuotedMsg (JSON)
ALTER TABLE chat_items ADD COLUMN quoted_sent INTEGER; -- from MessageRef, 1 for sent, 0 for received, NULL for messages without quote
ALTER TABLE chat_items ADD COLUMN quoted_member_id BLOB; -- from MessageRef
CREATE INDEX idx_chat_items_shared_msg_id ON chat_items (shared_msg_id);
|]
+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 =
+132 -39
View File
@@ -12,20 +12,25 @@
module Simplex.Chat.Protocol where
import Control.Applicative ((<|>))
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 Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
import Data.Time.Clock (UTCTime)
import Database.SQLite.Simple.FromField (FromField (..))
import Database.SQLite.Simple.ToField (ToField (..))
import GHC.Generics (Generic)
import Simplex.Chat.Types
import Simplex.Chat.Util (eitherToMaybe)
import Simplex.Chat.Util (eitherToMaybe, safeDecodeUtf8)
import Simplex.Messaging.Agent.Store.SQLite (fromTextField_)
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Util ((<$?>))
@@ -50,14 +55,51 @@ updateEntityConnStatus connEntity connStatus = case connEntity of
-- chat message is sent as JSON with these properties
data AppMessage = AppMessage
{ event :: Text,
{ msgId :: Maybe SharedMsgId,
event :: Text,
params :: J.Object
}
deriving (Generic, FromJSON)
instance ToJSON AppMessage where toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
instance ToJSON AppMessage where
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
newtype ChatMessage = ChatMessage {chatMsgEvent :: ChatMsgEvent}
newtype SharedMsgId = SharedMsgId ByteString
deriving (Eq, Show)
instance FromField SharedMsgId where fromField f = SharedMsgId <$> fromField f
instance ToField SharedMsgId where toField (SharedMsgId m) = toField m
instance StrEncoding SharedMsgId where
strEncode (SharedMsgId m) = strEncode m
strDecode s = SharedMsgId <$> strDecode s
strP = SharedMsgId <$> strP
instance FromJSON SharedMsgId where
parseJSON = strParseJSON "SharedMsgId"
instance ToJSON SharedMsgId where
toJSON = strToJSON
toEncoding = strToJEncoding
data MsgRef = MsgRef
{ msgId :: Maybe SharedMsgId,
sentAt :: UTCTime,
sent :: Bool,
memberId :: Maybe MemberId -- must be present in all group message references, both referencing sent and received
}
deriving (Eq, Show, Generic)
instance FromJSON MsgRef where
parseJSON = J.genericParseJSON J.defaultOptions {J.omitNothingFields = True}
instance ToJSON MsgRef where
toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True}
toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True}
data ChatMessage = ChatMessage {msgId :: Maybe SharedMsgId, chatMsgEvent :: ChatMsgEvent}
deriving (Eq, Show)
instance StrEncoding ChatMessage where
@@ -66,7 +108,7 @@ instance StrEncoding ChatMessage where
strP = strDecode <$?> A.takeByteString
data ChatMsgEvent
= XMsgNew MsgContent
= XMsgNew MsgContainer
| XFile FileInvitation
| XFileAcpt String
| XInfo Profile
@@ -87,63 +129,106 @@ data ChatMsgEvent
| XInfoProbeCheck ProbeHash
| XInfoProbeOk Probe
| XOk
| XUnknown {event :: Text, params :: J.Object}
deriving (Eq, Show)
data MsgContentType = MCText_ | MCUnknown_
data QuotedMsg = QuotedMsg {msgRef :: MsgRef, content :: MsgContent}
deriving (Eq, Show, Generic, FromJSON)
instance StrEncoding MsgContentType where
instance ToJSON QuotedMsg where
toEncoding = J.genericToEncoding J.defaultOptions
toJSON = J.genericToJSON J.defaultOptions
cmToQuotedMsg :: ChatMsgEvent -> Maybe QuotedMsg
cmToQuotedMsg = \case
XMsgNew (MCQuote quotedMsg _) -> Just quotedMsg
_ -> Nothing
data MsgContentTag = MCText_ | MCUnknown_ Text
instance StrEncoding MsgContentTag where
strEncode = \case
MCText_ -> "text"
MCUnknown_ -> "text"
MCUnknown_ t -> encodeUtf8 t
strDecode = \case
"text" -> Right MCText_
_ -> Right MCUnknown_
t -> Right . MCUnknown_ $ safeDecodeUtf8 t
strP = strDecode <$?> A.takeTill (== ' ')
instance FromJSON MsgContentType where
instance FromJSON MsgContentTag where
parseJSON = strParseJSON "MsgContentType"
instance ToJSON MsgContentType where
instance ToJSON MsgContentTag 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 MsgContainer
= MCSimple MsgContent
| MCQuote QuotedMsg MsgContent
| MCForward MsgContent
deriving (Eq, Show)
mcContent :: MsgContainer -> MsgContent
mcContent = \case
MCSimple c -> c
MCQuote _ c -> c
MCForward c -> c
data MsgContent
= MCText Text
| MCUnknown {tag :: Text, text :: Text, json :: J.Object}
deriving (Eq, Show)
msgContentText :: MsgContent -> Text
msgContentText = \case
MCText t -> t
MCUnknown -> unknownMsgType
MCUnknown {text} -> text
toMsgContentType :: MsgContent -> MsgContentType
toMsgContentType = \case
msgContentTag :: MsgContent -> MsgContentTag
msgContentTag = \case
MCText _ -> MCText_
MCUnknown -> MCUnknown_
MCUnknown {tag} -> MCUnknown_ tag
parseMsgContainer :: J.Object -> JT.Parser MsgContainer
parseMsgContainer v =
MCQuote <$> v .: "quote" <*> mc
<|> (v .: "forward" >>= \f -> (if f then MCForward else MCSimple) <$> mc)
<|> MCSimple <$> mc
where
mc = v .: "content"
instance FromJSON MsgContent where
parseJSON (J.Object v) = do
parseJSON (J.Object v) =
v .: "type" >>= \case
MCText_ -> MCText <$> v .: "text"
MCUnknown_ -> pure MCUnknown
MCUnknown_ tag -> do
text <- fromMaybe unknownMsgType <$> v .:? "text"
pure MCUnknown {tag, text, json = v}
parseJSON invalid =
JT.prependFailure "bad MsgContent, " (JT.typeMismatch "Object" invalid)
unknownMsgType :: Text
unknownMsgType = "unknown message type"
msgContainerJSON :: MsgContainer -> J.Object
msgContainerJSON = \case
MCQuote qm c -> JM.fromList ["quote" .= qm, "content" .= c]
MCForward c -> JM.fromList ["forward" .= True, "content" .= c]
MCSimple c -> JM.fromList ["content" .= c]
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 {json} -> J.Object json
MCText t -> J.object ["type" .= MCText_, "text" .= t]
toEncoding = \case
MCUnknown {json} -> JE.value $ J.Object json
MCText t -> J.pairs $ "type" .= MCText_ <> "text" .= t
instance ToField MsgContent where
toField = toField . safeDecodeUtf8 . LB.toStrict . J.encode
instance FromField MsgContent where
fromField = fromTextField_ $ J.decode . LB.fromStrict . encodeUtf8
data CMEventTag
= XMsgNew_
@@ -167,6 +252,7 @@ data CMEventTag
| XInfoProbeCheck_
| XInfoProbeOk_
| XOk_
| XUnknown_ Text
deriving (Eq, Show)
instance StrEncoding CMEventTag where
@@ -192,6 +278,7 @@ instance StrEncoding CMEventTag where
XInfoProbeCheck_ -> "x.info.probe.check"
XInfoProbeOk_ -> "x.info.probe.ok"
XOk_ -> "x.ok"
XUnknown_ t -> encodeUtf8 t
strDecode = \case
"x.msg.new" -> Right XMsgNew_
"x.file" -> Right XFile_
@@ -214,7 +301,7 @@ instance StrEncoding CMEventTag where
"x.info.probe.check" -> Right XInfoProbeCheck_
"x.info.probe.ok" -> Right XInfoProbeOk_
"x.ok" -> Right XOk_
_ -> Left "bad CMEventTag"
t -> Right . XUnknown_ $ safeDecodeUtf8 t
strP = strDecode <$?> A.takeTill (== ' ')
toCMEventTag :: ChatMsgEvent -> CMEventTag
@@ -240,6 +327,7 @@ toCMEventTag = \case
XInfoProbeCheck _ -> XInfoProbeCheck_
XInfoProbeOk _ -> XInfoProbeOk_
XOk -> XOk_
XUnknown t _ -> XUnknown_ t
cmEventTagT :: Text -> Maybe CMEventTag
cmEventTagT = eitherToMaybe . strDecode . encodeUtf8
@@ -252,19 +340,21 @@ instance FromField CMEventTag where fromField = fromTextField_ cmEventTagT
instance ToField CMEventTag where toField = toField . serializeCMEventTag
appToChatMessage :: AppMessage -> Either String ChatMessage
appToChatMessage AppMessage {event, params} = do
appToChatMessage AppMessage {msgId, event, params} = do
eventTag <- strDecode $ encodeUtf8 event
chatMsgEvent <- msg eventTag
pure ChatMessage {chatMsgEvent}
pure ChatMessage {msgId, chatMsgEvent}
where
p :: FromJSON a => J.Key -> Either String a
p key = JT.parseEither (.: key) params
opt :: FromJSON a => J.Key -> Either String (Maybe a)
opt key = JT.parseEither (.:? key) params
msg = \case
XMsgNew_ -> XMsgNew <$> p "content"
XMsgNew_ -> XMsgNew <$> JT.parseEither parseMsgContainer params
XFile_ -> XFile <$> p "file"
XFileAcpt_ -> XFileAcpt <$> p "fileName"
XInfo_ -> XInfo <$> p "profile"
XContact_ -> XContact <$> p "profile" <*> JT.parseEither (.:? "contactReqId") params
XContact_ -> XContact <$> p "profile" <*> opt "contactReqId"
XGrpInv_ -> XGrpInv <$> p "groupInvitation"
XGrpAcpt_ -> XGrpAcpt <$> p "memberId"
XGrpMemNew_ -> XGrpMemNew <$> p "memberInfo"
@@ -281,19 +371,21 @@ appToChatMessage AppMessage {event, params} = do
XInfoProbeCheck_ -> XInfoProbeCheck <$> p "probeHash"
XInfoProbeOk_ -> XInfoProbeOk <$> p "probe"
XOk_ -> pure XOk
XUnknown_ t -> pure $ XUnknown t params
chatToAppMessage :: ChatMessage -> AppMessage
chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params}
chatToAppMessage ChatMessage {msgId, chatMsgEvent} = AppMessage {msgId, event, params}
where
event = serializeCMEventTag . toCMEventTag $ chatMsgEvent
o :: [(J.Key, J.Value)] -> J.Object
o = JM.fromList
key .=? value = maybe id ((:) . (key .=)) value
params = case chatMsgEvent of
XMsgNew content -> o ["content" .= content]
XMsgNew container -> msgContainerJSON container
XFile fileInv -> o ["file" .= fileInv]
XFileAcpt fileName -> o ["fileName" .= fileName]
XInfo profile -> o $ ["profile" .= profile]
XContact profile xContactId -> o $ maybe id ((:) . ("contactReqId" .=)) xContactId ["profile" .= profile]
XInfo profile -> o ["profile" .= profile]
XContact profile xContactId -> o $ ("contactReqId" .=? xContactId) ["profile" .= profile]
XGrpInv groupInv -> o ["groupInvitation" .= groupInv]
XGrpAcpt memId -> o ["memberId" .= memId]
XGrpMemNew memInfo -> o ["memberInfo" .= memInfo]
@@ -310,3 +402,4 @@ chatToAppMessage ChatMessage {chatMsgEvent} = AppMessage {event, params}
XInfoProbeCheck probeHash -> o ["probeHash" .= probeHash]
XInfoProbeOk probe -> o ["probe" .= probe]
XOk -> JM.empty
XUnknown _ ps -> ps
+555 -228
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -48,6 +48,8 @@ runInputLoop ct cc = forever $ do
Right SendGroupMessage {} -> True
Right SendFile {} -> True
Right SendGroupFile {} -> True
Right SendMessageQuote {} -> True
Right SendGroupMessageQuote {} -> True
_ -> False
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
@@ -98,8 +100,10 @@ updateTermState ac tw (key, ms) ts@TerminalState {inputString = s, inputPosition
_ -> ts
where
insertCharsWithContact cs
| null s && cs /= "@" && cs /= "#" && cs /= "/" =
| null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" =
insertChars $ contactPrefix <> cs
| s == ">" && cs == " " =
insertChars $ cs <> contactPrefix
| otherwise = insertChars cs
insertChars = ts' . if p >= length s then append else insert
append cs = let s' = s <> cs in (s', length s')
+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,
+125 -48
View File
@@ -4,14 +4,18 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
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 +43,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
@@ -51,6 +57,7 @@ responseToView testView = \case
HSFiles -> filesHelpInfo
HSGroups -> groupsHelpInfo
HSMyAddress -> myAddressHelpInfo
HSQuotes -> quotesHelpInfo
HSMarkdown -> markdownInfo
CRWelcome user -> chatWelcome user
CRContactsList cs -> viewContactsList cs
@@ -103,9 +110,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"
[sShow (length subscribed) <> " contacts connected (use " <> highlight' "/cs" <> " for the list)" | not (null subscribed)] <> 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 +129,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 ->
@@ -144,37 +152,60 @@ responseToView testView = \case
testViewChat :: AChat -> [StyledString]
testViewChat (AChat _ Chat {chatItems}) = [sShow $ map toChatView chatItems]
where
toChatView :: CChatItem c -> (Int, Text)
toChatView (CChatItem dir ChatItem {meta}) = (msgDirectionInt $ toMsgDirection dir, itemText meta)
toChatView :: CChatItem c -> ((Int, Text), Maybe (Int, Text))
toChatView (CChatItem dir ChatItem {meta, quotedItem}) =
((msgDirectionInt $ toMsgDirection dir, itemText meta),) $ case quotedItem of
Nothing -> Nothing
Just CIQuote {chatDir = quoteDir, content} ->
Just (msgDirectionInt $ quoteMsgDirection quoteDir, msgContentText content)
viewErrorsSummary :: [a] -> StyledString -> [StyledString]
viewErrorsSummary summary s = if null summary then [] else [styled (colored Red) (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)"]
viewErrorsSummary summary s = [ttyError (T.pack . show $ length summary) <> s <> " (run with -c option to show each error)" | not (null summary)]
viewChatItem :: ChatInfo c -> ChatItem c d -> [StyledString]
viewChatItem chat (ChatItem cd meta content _) = case (chat, cd) of
(DirectChat c, CIDirectSnd) -> case content of
CISndMsgContent mc -> viewSentMessage to mc meta
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
viewChatItem :: MsgDirectionI d => ChatInfo c -> ChatItem c d -> [StyledString]
viewChatItem chat ChatItem {chatDir, meta, content, quotedItem} = case chat of
DirectChat c -> case chatDir of
CIDirectSnd -> case content of
CISndMsgContent mc -> viewSentMessage to quote mc meta
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
where
to = ttyToContact' c
CIDirectRcv -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft
where
from = ttyFromContact' c
where
to = ttyToContact' c
(DirectChat c, CIDirectRcv) -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from meta mc -- mOk
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk
quote = maybe [] (directQuote chatDir) quotedItem
GroupChat g -> case chatDir of
CIGroupSnd -> case content of
CISndMsgContent mc -> viewSentMessage to quote mc meta
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
where
to = ttyToGroup g
CIGroupRcv m -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from quote meta mc
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft
where
from = ttyFromGroup' g m
where
from = ttyFromContact' c
(GroupChat g, CIGroupSnd) -> case content of
CISndMsgContent mc -> viewSentMessage to mc meta
CISndFileInvitation fId fPath -> viewSentFileInvitation to fId fPath meta
where
to = ttyToGroup g
(GroupChat g, CIGroupRcv m) -> case content of
CIRcvMsgContent mc -> viewReceivedMessage from meta mc -- mOk
CIRcvFileInvitation ft -> viewReceivedFileInvitation from meta ft -- mOk
where
from = ttyFromGroup' g m
quote = maybe [] (groupQuote g) quotedItem
_ -> []
where
ttyToContact' Contact {localDisplayName = c} = ttyToContact c
ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c
ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m
directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString]
directQuote _ CIQuote {content = qmc, chatDir = qouteDir} =
quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection qouteDir then ">>" else ">"
groupQuote :: GroupInfo -> CIQuote 'CTGroup -> [StyledString]
groupQuote g CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc . ttyQuotedMember $ sentByMember g quoteDir
sentByMember :: GroupInfo -> CIQDirection 'CTGroup -> Maybe GroupMember
sentByMember GroupInfo {membership} = \case
CIQGroupSnd -> Just membership
CIQGroupRcv m -> m
quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc
msgPreview = msgPlain . preview . msgContentText
where
preview t
| T.length t <= 60 = t
| otherwise = t <> "..."
viewMsgIntegrityError :: MsgErrorType -> [StyledString]
viewMsgIntegrityError err = msgError $ case err of
@@ -186,7 +217,7 @@ viewMsgIntegrityError err = msgError $ case err of
MsgDuplicate -> "duplicate message ID"
where
msgError :: String -> [StyledString]
msgError s = [styled (colored Red) s]
msgError s = [ttyError s]
viewInvalidConnReq :: [StyledString]
viewInvalidConnReq =
@@ -314,9 +345,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
@@ -335,13 +384,14 @@ viewContactUpdated
where
fullNameUpdate = if T.null fullName' || fullName' == n' then " removed full name" else " updated full name: " <> plain fullName'
viewReceivedMessage :: StyledString -> CIMeta d -> MsgContent -> [StyledString]
viewReceivedMessage from meta mc = receivedWithTime_ from meta (ttyMsgContent mc)
viewReceivedMessage :: StyledString -> [StyledString] -> CIMeta d -> MsgContent -> [StyledString]
viewReceivedMessage from quote meta = receivedWithTime_ from quote meta . ttyMsgContent
receivedWithTime_ :: StyledString -> CIMeta d -> [StyledString] -> [StyledString]
receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do
prependFirst (formattedTime <> " " <> from) styledMsg -- ++ showIntegrity mOk
receivedWithTime_ :: StyledString -> [StyledString] -> CIMeta d -> [StyledString] -> [StyledString]
receivedWithTime_ from quote CIMeta {localItemTs, createdAt} styledMsg = do
prependFirst (formattedTime <> " " <> from) (quote <> prependFirst indent styledMsg)
where
indent = if null quote then "" else " "
formattedTime :: StyledString
formattedTime =
let localTime = zonedTimeToLocalTime localItemTs
@@ -353,8 +403,10 @@ receivedWithTime_ from CIMeta {localItemTs, createdAt} styledMsg = do
else "%H:%M"
in styleTime $ formatTime defaultTimeLocale format localTime
viewSentMessage :: StyledString -> MsgContent -> CIMeta d -> [StyledString]
viewSentMessage to = sentWithTime_ . prependFirst to . ttyMsgContent
viewSentMessage :: StyledString -> [StyledString] -> MsgContent -> CIMeta d -> [StyledString]
viewSentMessage to quote mc = sentWithTime_ . prependFirst to $ quote <> prependFirst indent (ttyMsgContent mc)
where
indent = if null quote then "" else " "
viewSentFileInvitation :: StyledString -> FileTransferId -> FilePath -> CIMeta d -> [StyledString]
viewSentFileInvitation to fId fPath = sentWithTime_ $ ttySentFile to fId fPath
@@ -367,9 +419,7 @@ ttyMsgTime :: ZonedTime -> StyledString
ttyMsgTime = styleTime . formatTime defaultTimeLocale "%H:%M"
ttyMsgContent :: MsgContent -> [StyledString]
ttyMsgContent = \case
MCText t -> msgPlain t
MCUnknown -> ["unknown message type"]
ttyMsgContent = msgPlain . msgContentText
ttySentFile :: StyledString -> FileTransferId -> FilePath -> [StyledString]
ttySentFile to fId fPath = ["/f " <> to <> ttyFilePath fPath, "use " <> highlight ("/fc " <> show fId) <> " to cancel sending"]
@@ -399,7 +449,7 @@ sndFile :: SndFileTransfer -> StyledString
sndFile SndFileTransfer {fileId, fileName} = fileTransferStr fileId fileName
viewReceivedFileInvitation :: StyledString -> CIMeta d -> RcvFileTransfer -> [StyledString]
viewReceivedFileInvitation from meta ft = receivedWithTime_ from meta (receivedFileInvitation_ ft)
viewReceivedFileInvitation from meta ft = receivedWithTime_ from [] meta (receivedFileInvitation_ ft)
receivedFileInvitation_ :: RcvFileTransfer -> [StyledString]
receivedFileInvitation_ RcvFileTransfer {fileId, fileInvitation = FileInvitation {fileName, fileSize}} =
@@ -501,6 +551,7 @@ viewChatError = \case
CEFileSend fileId e -> ["error sending file " <> sShow fileId <> ": " <> sShow e]
CEFileRcvChunk e -> ["error receiving file: " <> plain e]
CEFileInternal e -> ["file error: " <> plain e]
CEInvalidQuote -> ["cannot reply to this message"]
CEAgentVersion -> ["unsupported agent version"]
CECommandError e -> ["bad chat command: " <> plain e]
-- e -> ["chat error: " <> sShow e]
@@ -517,6 +568,7 @@ viewChatError = \case
SEUserContactLinkNotFound -> ["no chat address, to create: " <> highlight' "/ad"]
SEContactRequestNotFoundByName c -> ["no contact request from " <> ttyContact c]
SEConnectionNotFound _ -> [] -- TODO mutes delete group error, but also mutes any error from getConnectionEntity
SEQuotedChatItemNotFound -> ["message not found - reply is not sent"]
e -> ["chat db error: " <> sShow e]
ChatErrorAgent err -> case err of
SMP SMP.AUTH -> ["error: this connection is deleted"]
@@ -525,7 +577,7 @@ viewChatError = \case
fileNotFound fileId = ["file " <> sShow fileId <> " not found"]
ttyContact :: ContactName -> StyledString
ttyContact = styled (colored Green)
ttyContact = styled $ colored Green
ttyContact' :: Contact -> StyledString
ttyContact' Contact {localDisplayName = c} = ttyContact c
@@ -548,7 +600,20 @@ ttyToContact :: ContactName -> StyledString
ttyToContact c = styled (colored Cyan) $ "@" <> c <> " "
ttyFromContact :: ContactName -> StyledString
ttyFromContact c = styled (colored Yellow) $ c <> "> "
ttyFromContact c = ttyFrom $ c <> "> "
ttyToContact' :: Contact -> StyledString
ttyToContact' Contact {localDisplayName = c} = ttyToContact c
ttyQuotedContact :: Contact -> StyledString
ttyQuotedContact Contact {localDisplayName = c} = ttyFrom $ c <> ">"
ttyQuotedMember :: Maybe GroupMember -> StyledString
ttyQuotedMember (Just GroupMember {localDisplayName = c}) = "> " <> ttyFrom c
ttyQuotedMember _ = "> " <> ttyFrom "?"
ttyFromContact' :: Contact -> StyledString
ttyFromContact' Contact {localDisplayName = c} = ttyFromContact c
ttyGroup :: GroupName -> StyledString
ttyGroup g = styled (colored Blue) $ "#" <> g
@@ -566,7 +631,13 @@ ttyFullGroup GroupInfo {localDisplayName = g, groupProfile = GroupProfile {fullN
ttyGroup g <> optFullName g fullName
ttyFromGroup :: GroupInfo -> ContactName -> StyledString
ttyFromGroup GroupInfo {localDisplayName = g} c = styled (colored Yellow) $ "#" <> g <> " " <> c <> "> "
ttyFromGroup GroupInfo {localDisplayName = g} c = ttyFrom $ "#" <> g <> " " <> c <> "> "
ttyFrom :: Text -> StyledString
ttyFrom = styled $ colored Yellow
ttyFromGroup' :: GroupInfo -> GroupMember -> StyledString
ttyFromGroup' g GroupMember {localDisplayName = m} = ttyFromGroup g m
ttyToGroup :: GroupInfo -> StyledString
ttyToGroup GroupInfo {localDisplayName = g} = styled (colored Cyan) $ "#" <> g <> " "
@@ -580,10 +651,16 @@ optFullName localDisplayName fullName
| otherwise = plain (" (" <> fullName <> ")")
highlight :: StyledFormat a => a -> StyledString
highlight = styled (colored Cyan)
highlight = styled $ colored Cyan
highlight' :: String -> StyledString
highlight' = highlight
styleTime :: String -> StyledString
styleTime = Styled [SetColor Foreground Vivid Black]
ttyError :: StyledFormat a => a -> StyledString
ttyError = styled $ colored Red
ttyError' :: String -> StyledString
ttyError' = ttyError

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