Compare commits

...

108 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
Efim Poberezkin 6086f76d95 1.3.0 2022-02-26 16:41:11 +04:00
Efim Poberezkin 268eaaa9ca prepare v1.3.0 (#378) 2022-02-26 16:24:56 +04:00
Evgeny Poberezkin ad1612d84a android: markdown help (#376) 2022-02-26 15:35:51 +04:00
Evgeny Poberezkin 0389a58f64 core: fix failing subscriptions when user address is missing (#377)
* core: fix failing subscriptions when user address is missing

* set concurrency limit on subscriptions
2022-02-26 10:04:25 +00:00
Evgeny Poberezkin 6ee2f334f6 update build number (12) 2022-02-26 08:24:58 +00:00
Evgeny Poberezkin a5afdf4e91 ios: update version/build 0.4 (11) 2022-02-25 21:57:05 +00:00
Evgeny Poberezkin ecaa570ff3 free C strings (#375) 2022-02-25 21:07:36 +00:00
Evgeny Poberezkin 1d2d1e6df7 process subscription summaries in ios/android (#374) 2022-02-25 20:26:56 +00:00
Efim Poberezkin c242f0079c core: add fks to messages (#368) 2022-02-25 21:59:35 +04:00
Evgeny Poberezkin 727c533f93 update build number to 0.2 (3) 2022-02-25 15:29:52 +00:00
Efim Poberezkin 5961b7d951 asynchronously subscribe to user connections (#310)
* asynchronously subscribe to user connections

* send subscription status summaries to view/api

* refactor

* add help messages in summaries

* update simplexmq

* rename config field

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-25 12:29:36 +00:00
Evgeny Poberezkin bbab069bcd android: replace while true loop with async recursion (#371) 2022-02-25 11:37:47 +00:00
Evgeny Poberezkin 1cf3b776d7 ios: use core markdown parser, also make messages in android selectable (#372)
* ios: use core markdown parser, also make messages in android selectable

* remove bold font from members in previews

* markdown help

* text selection
2022-02-25 07:16:19 +00:00
Evgeny Poberezkin 1aa2643c18 android: show member names in group messages (#370)
* android: show member names in group messages

* refactor
2022-02-24 18:02:59 +00:00
Evgeny Poberezkin 1150c04298 ios: process commands and messages asynchronously, on the background thread (#367)
* ios: process commands and messages asynchronously, on the background thread

* move model updates to main thread
2022-02-24 17:16:41 +00:00
Efim Poberezkin 9c57ab5221 android: user address; fix add and connect contact views dark mode; chat list view styling (#359) 2022-02-24 12:58:59 +04:00
IanRDavies 3e61b8c21a ios: display name validation (#364)
* try to add warning text if display name has whitespace

* simplify

* layout/error icon

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2022-02-24 08:45:19 +00:00
IanRDavies 99bed645f3 android: more welcome styling (#363)
* spacing and size updates to welcome page

* spacing and allow space for keyboard
2022-02-24 08:01:41 +00:00
Evgeny Poberezkin 51f5982205 markdown: parse emails and phone numbers (#365)
* markdown: parse emails and phone numbers

* phone parsing

* refactor
2022-02-24 07:55:18 +00:00
Evgeny Poberezkin b7a06dd0cf show date on the same line as the message if space allows (#362) 2022-02-23 21:48:48 +04:00
IanRDavies e071e4cdbf add check for whitespace in display name (#360) 2022-02-23 12:40:50 +00:00
Evgeny Poberezkin 470b18786e android: show markdown in messages (#361)
* android: show markdown in messages

* empty line
2022-02-23 12:30:48 +00:00
Evgeny Poberezkin 8f21453e82 fix markdown type for Colored, add types/parsing for formatted text to iOS/android (#358) 2022-02-23 08:45:49 +00:00
Evgeny Poberezkin fb76917ec3 android: update version/build 0.2 (2) 2022-02-23 07:35:22 +00:00
Evgeny Poberezkin c53500812c android: fix bottom sheet delay and graying out the rest of the screen (#356) 2022-02-22 20:52:02 +00:00
Evgeny Poberezkin 5e6b9e578b smaller size for unread count, show 1000s as Ks (#355) 2022-02-22 19:43:29 +00:00
Efim Poberezkin f9c495a596 android: help view (#351)
* android: help view

* chats loaded

* remove comment
2022-02-22 19:38:47 +00:00
IanRDavies e4ec8cccfd android: theme welcome view page (#354)
* initial theming changes

* styling work round 1
2022-02-22 19:32:43 +00:00
IanRDavies 3be350786d android: update logo (#350)
* add updated icon assets

* pure white splash
2022-02-22 19:31:38 +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
158 changed files with 6336 additions and 2225 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
+7 -1
View File
@@ -3,6 +3,7 @@
<JetCodeStyleSettings>
<option name="SPACE_BEFORE_EXTEND_COLON" value="false" />
<option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="3" />
<option name="WRAP_EXPRESSION_BODY_FUNCTIONS" value="0" />
<option name="WRAP_ELVIS_EXPRESSIONS" value="0" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
@@ -121,10 +122,15 @@
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
<option name="RIGHT_MARGIN" value="120" />
<option name="RIGHT_MARGIN" value="140" />
<option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
<option name="KEEP_BLANK_LINES_IN_CODE" value="0" />
<option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
<option name="CALL_PARAMETERS_WRAP" value="0" />
<option name="METHOD_PARAMETERS_WRAP" value="0" />
<option name="EXTENDS_LIST_WRAP" value="0" />
<option name="METHOD_CALL_CHAIN_WRAP" value="0" />
<option name="ASSIGNMENT_WRAP" value="0" />
<option name="METHOD_ANNOTATION_WRAP" value="0" />
<option name="CLASS_ANNOTATION_WRAP" value="0" />
<option name="FIELD_ANNOTATION_WRAP" value="0" />
+15 -3
View File
@@ -9,10 +9,10 @@ android {
defaultConfig {
applicationId "chat.simplex.app"
minSdk 26
minSdk 29
targetSdk 32
versionCode 1
versionName "1.0"
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}"
@@ -11,9 +11,8 @@
<application
android:name="SimplexApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:icon="@mipmap/icon"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.SimpleX">
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

@@ -2,159 +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.Modifier
import androidx.lifecycle.AndroidViewModel
import androidx.navigation.*
import androidx.navigation.compose.*
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.SettingsView
import chat.simplex.app.views.usersettings.UserProfileView
import com.google.accompanist.insets.ExperimentalAnimatedInsets
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import kotlinx.coroutines.DelicateCoroutinesApi
@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)
connectIfOpenedViaUri(intent, vm.chatModel)
// testJson()
processIntent(intent, vm.chatModel)
// vm.app.initiateBackgroundWork()
setContent {
SimpleXTheme {
Navigation(vm.chatModel)
}
}
}
}
@DelicateCoroutinesApi
class SimplexViewModel(application: Application): AndroidViewModel(application) {
val chatModel = getApplication<SimplexApp>().chatModel
}
@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)
}
}
@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.Settings.route) {
SettingsView(chatModel, nav)
}
composable(route = Pages.UserProfile.route) {
UserProfileView(chatModel, 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 Settings: Pages("settings")
object UserProfile: Pages("user_profile")
}
@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)
}
}
)
}
}
}
}
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,39 +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 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 {
@@ -83,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
@@ -97,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)
}
}
}
@@ -1,14 +1,22 @@
package chat.simplex.app.model
import android.net.Uri
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import chat.simplex.app.SimplexApp
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
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.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.*
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>()
@@ -17,6 +25,8 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale
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)
@@ -54,14 +64,15 @@ class ChatModel(val controller: ChatController, val alertManager: SimplexApp.Ale
}
}
// func replaceChat(_ id: String, _ chat: Chat) {
// if let i = getChatIndex(id) {
// chats[i] = chat
// } else {
// // invalid state, correcting
// chats.insert(chat, at: 0)
// }
// }
fun replaceChat(id: String, chat: Chat) {
val i = getChatIndex(id)
if (i >= 0) {
chats[i] = chat
} else {
// invalid state, correcting
chats.add(index = 0, chat)
}
}
fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) {
// update previews
@@ -230,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()
@@ -240,6 +251,13 @@ data class Chat (
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus()
}
companion object {
val sampleData = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = arrayListOf(ChatItem.getSampleData())
)
}
}
@Serializable
@@ -320,6 +338,12 @@ class Contact(
}
}
@Serializable
class ContactSubStatus(
val contact: Contact,
val contactError: ChatError? = null
)
@Serializable
class Connection(val connStatus: String) {
companion object {
@@ -401,6 +425,12 @@ class GroupMember (
}
}
@Serializable
class MemberSubError (
val member: GroupMember,
val memberError: ChatError
)
@Serializable
class UserContactRequest (
val contactRequestId: Long,
@@ -435,18 +465,32 @@ class AChatItem (
data class ChatItem (
val chatDir: CIDirection,
val meta: CIMeta,
val content: CIContent
val content: CIContent,
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, dir: CIDirection, ts: Instant, text: String,status: CIStatus = CIStatus.SndNew()) =
fun getSampleData(
id: Long = 1,
dir: CIDirection = CIDirection.DirectSnd(),
ts: Instant = Clock.System.now(),
text: String = "hello\nthere",
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
)
}
}
@@ -529,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() {
@@ -555,17 +603,145 @@ 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)
}
}
@Serializable
class RcvFileTransfer {
class FormattedText(val text: String, val format: Format? = null) {
val link: String? = when (format) {
is Format.Uri -> text
is Format.Email -> "mailto:$text"
is Format.Phone -> "tel:$text"
else -> null
}
}
@Serializable
sealed class Format {
@Serializable @SerialName("bold") class Bold: Format()
@Serializable @SerialName("italic") class Italic: Format()
@Serializable @SerialName("strikeThrough") class StrikeThrough: Format()
@Serializable @SerialName("snippet") class Snippet: Format()
@Serializable @SerialName("secret") class Secret: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format()
val style: SpanStyle @Composable get() = when (this) {
is Bold -> SpanStyle(fontWeight = FontWeight.Bold)
is Italic -> SpanStyle(fontStyle = FontStyle.Italic)
is StrikeThrough -> SpanStyle(textDecoration = TextDecoration.LineThrough)
is Snippet -> SpanStyle(fontFamily = FontFamily.Monospace)
is Secret -> SpanStyle(color = Color.Transparent, background = SecretColor)
is Colored -> SpanStyle(color = this.color.uiColor)
is Uri -> linkStyle
is Email -> linkStyle
is Phone -> linkStyle
}
companion object {
val linkStyle @Composable get() = SpanStyle(color = MaterialTheme.colors.primary, textDecoration = TextDecoration.Underline)
}
}
@Serializable
enum class FormatColor(val color: String) {
red("red"),
green("green"),
blue("blue"),
yellow("yellow"),
cyan("cyan"),
magenta("magenta"),
black("black"),
white("white");
val uiColor: Color @Composable get() = when (this) {
red -> Color.Red
green -> Color.Green
blue -> SimplexBlue
yellow -> Color.Yellow
cyan -> Color.Cyan
magenta -> Color.Magenta
black -> MaterialTheme.colors.onBackground
white -> MaterialTheme.colors.onBackground
}
}
@Serializable
class RcvFileTransfer
@@ -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,9 +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 kotlinx.coroutines.*
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.*
@@ -13,36 +19,42 @@ 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.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
}
}
fun startReceiver() {
thread(name="receiver") {
// val chatlog = FifoQueue<String>(500)
while(true) {
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")
processReceivedMsg(r)
withApi { recvMspLoop() }
}
}
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 {
@@ -50,21 +62,36 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
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
}
}
suspend fun recvMsg(): CR {
return withContext(Dispatchers.IO) {
val json = chatRecvMsg(ctrl)
val r = APIResponse.decodeStr(json).resp
Log.d(TAG, "chatRecvMsg: ${r.responseType}")
if (r is CR.Response || r is CR.Invalid) Log.d(TAG, "chatRecvMsg json: $json")
r
}
}
suspend fun recvMspLoop() {
processReceivedMsg(recvMsg())
recvMspLoop()
}
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
}
@@ -72,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}")
}
@@ -91,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
}
@@ -114,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
@@ -135,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
}
@@ -156,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
}
@@ -181,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) {
@@ -220,62 +270,70 @@ open class ChatController(val ctrl: ChatCtrl, val alertManager: SimplexApp.Alert
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
// NtfManager.shared.notifyContactConnected(contact)
}
// is CR.ReceivedContactRequest -> return
is CR.ReceivedContactRequest -> {
val contactRequest = r.contactRequest
val cInfo = ChatInfo.ContactRequest(contactRequest)
chatModel.addChat(Chat(chatInfo = cInfo, chatItems = listOf()))
// NtfManager.shared.notifyContactRequest(contactRequest)
}
is CR.ContactUpdated -> {
val cInfo = ChatInfo.Direct(r.toContact)
if (chatModel.hasChat(r.toContact.id)) {
chatModel.updateChatInfo(cInfo)
}
}
is CR.ContactSubscribed -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Connected())
}
is CR.ContactSubscribed -> processContactSubscribed(r.contact)
is CR.ContactDisconnected -> {
chatModel.updateContact(r.contact)
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Disconnected())
}
is CR.ContactSubError -> {
chatModel.updateContact(r.contact)
val e = r.chatError
val err: String =
if (e is ChatError.ChatErrorAgent) {
val a = e.agentError
when {
a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
else -> e.string
}
}
else e.string
chatModel.updateNetworkStatus(r.contact, Chat.NetworkStatus.Error(err))
is CR.ContactSubError -> processContactSubError(r.contact, r.chatError)
is CR.ContactSubSummary -> {
for (sub in r.contactSubscriptions) {
val err = sub.contactError
if (err == null) processContactSubscribed(sub.contact)
else processContactSubError(sub.contact, sub.contactError)
}
}
is CR.NewChatItem -> {
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)
}
}
// switch res {
// case let .receivedContactRequest(contactRequest):
// chatModel.addChat(Chat(
// chatInfo: ChatInfo.contactRequest(contactRequest: contactRequest),
// chatItems: []
// ))
// NtfManager.shared.notifyContactRequest(contactRequest)
//
// case let .chatItemUpdated(aChatItem):
// let cInfo = aChatItem.chatInfo
// let cItem = aChatItem.chatItem
// 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}")
}
}
fun processContactSubscribed(contact: Contact) {
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Connected())
}
fun processContactSubError(contact: Contact, chatError: ChatError) {
chatModel.updateContact(contact)
val e = chatError
val err: String =
if (e is ChatError.ChatErrorAgent) {
val a = e.agentError
when {
a is AgentErrorType.BROKER && a.brokerErr is BrokerErrorType.NETWORK -> "network"
a is AgentErrorType.SMP && a.smpErr is SMPErrorType.AUTH -> "contact deleted"
else -> e.string
}
}
else e.string
chatModel.updateNetworkStatus(contact, Chat.NetworkStatus.Error(err))
}
}
// ChatCommand
@@ -287,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()
@@ -306,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)}"
@@ -326,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"
@@ -342,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 = ",")
}
}
@@ -358,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)),
@@ -376,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()
@@ -396,7 +468,9 @@ sealed class CR {
@Serializable @SerialName("contactSubscribed") class ContactSubscribed(val contact: Contact): CR()
@Serializable @SerialName("contactDisconnected") class ContactDisconnected(val contact: Contact): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val contactSubscriptions: List<ContactSubStatus>): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val group: GroupInfo): CR()
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val memberSubErrors: List<MemberSubError>): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@Serializable @SerialName("newChatItem") class NewChatItem(val chatItem: AChatItem): CR()
@@ -410,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"
@@ -430,7 +506,9 @@ sealed class CR {
is ContactSubscribed -> "contactSubscribed"
is ContactDisconnected -> "contactDisconnected"
is ContactSubError -> "contactSubError"
is ContactSubSummary -> "contactSubSummary"
is GroupSubscribed -> "groupSubscribed"
is MemberSubErrors -> "memberSubErrors"
is GroupEmpty -> "groupEmpty"
is UserContactLinkSubscribed -> "userContactLinkSubscribed"
is NewChatItem -> "newChatItem"
@@ -445,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()
@@ -465,7 +545,9 @@ sealed class CR {
is ContactSubscribed -> json.encodeToString(contact)
is ContactDisconnected -> json.encodeToString(contact)
is ContactSubError -> "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}"
is ContactSubSummary -> json.encodeToString(contactSubscriptions)
is GroupSubscribed -> json.encodeToString(group)
is MemberSubErrors -> json.encodeToString(memberSubErrors)
is GroupEmpty -> json.encodeToString(group)
is UserContactLinkSubscribed -> noDetails()
is NewChatItem -> json.encodeToString(chatItem)
@@ -9,6 +9,7 @@ val Teal200 = Color(0xFF03DAC5)
val Gray = Color(0x22222222)
val SimplexBlue = Color(0, 136, 255, 255)
val SimplexGreen = Color(98, 196, 103, 255)
val SecretColor = Color(0x40808080)
val LightGray = Color(241, 242, 246, 255)
val DarkGray = Color(43, 44, 46, 255)
val HighOrLowlight = Color(134, 135, 139, 255)
@@ -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,23 +1,25 @@
package chat.simplex.app.views
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.background
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.res.painterResource
import androidx.compose.ui.unit.dp
import chat.simplex.app.R
@Composable
fun SplashView() {
Box(modifier = Modifier.fillMaxSize()) {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = "Simplex Icon",
modifier = Modifier
.height(230.dp)
.align(Alignment.Center)
)
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
// Image(
// painter = painterResource(R.drawable.logo),
// contentDescription = "Simplex Icon",
// modifier = Modifier
// .height(230.dp)
// .align(Alignment.Center)
// )
}
}
@@ -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.*
@@ -11,24 +13,24 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
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))
@@ -38,36 +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),
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) {
@@ -78,20 +89,6 @@ fun TerminalLog(terminalItems: List<TerminalItem>, navigate: (String) -> Unit) {
}
}
@Composable
fun DetailView(identifier: Long, terminalItems: List<TerminalItem>, navController: NavController){
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Button(onClick = { navController.popBackStack() }) {
Text("Back")
}
SelectionContainer {
Text((terminalItems.firstOrNull { it.id == identifier })?.details ?: "")
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -104,7 +101,6 @@ fun PreviewTerminalLayout() {
TerminalLayout(
terminalItems = TerminalItem.sampleData,
close = {},
navigate = {},
sendCommand = {}
)
}
@@ -2,58 +2,158 @@ package chat.simplex.app.views
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.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.app.R
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.Profile
import chat.simplex.app.views.helpers.withApi
import kotlinx.coroutines.DelicateCoroutinesApi
import com.google.accompanist.insets.ProvideWindowInsets
import com.google.accompanist.insets.navigationBarsWithImePadding
@DelicateCoroutinesApi
@Composable
fun WelcomeView(chatModel: ChatModel, routeHome: () -> Unit) {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Image(
painter=painterResource(R.drawable.logo), contentDescription = "Simplex Logo",
)
Text("You control your chat!")
Text("The messaging and application platform protecting your privacy and security.")
Spacer(Modifier.height(8.dp))
Text("We don't store any of your contacts or messages (once delivered) on the servers.")
Spacer(Modifier.height(24.dp))
CreateProfilePanel(chatModel, routeHome)
fun WelcomeView(chatModel: ChatModel) {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
.background(color = MaterialTheme.colors.background)
.padding(12.dp)
) {
Image(
painter = painterResource(R.drawable.logo),
contentDescription = "Simplex Logo",
modifier = Modifier.padding(vertical = 15.dp)
)
Text(
"You control your chat!",
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground
)
Text(
"The messaging and application platform protecting your privacy and security.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(8.dp))
Text(
"We don't store any of your contacts or messages (once delivered) on the servers.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(24.dp))
CreateProfilePanel(chatModel)
}
}
}
}
@DelicateCoroutinesApi
fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null
}
@Composable
fun CreateProfilePanel(chatModel: ChatModel, routeHome: () -> Unit) {
fun CreateProfilePanel(chatModel: ChatModel) {
var displayName by remember { mutableStateOf("") }
var fullName by remember { mutableStateOf("") }
Column {
Text("Create profile")
Text("Your profile is stored on your device and shared only with your contacts.")
Text("Display Name")
TextField(value = displayName, onValueChange = { value -> displayName = value })
Text("Full Name (Optional)")
TextField(value = fullName, onValueChange = { fullName = it })
Button(onClick={
Column(
modifier=Modifier.fillMaxSize()
) {
Text(
"Create profile",
style = MaterialTheme.typography.h4,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(vertical = 5.dp)
)
Text(
"Your profile is stored on your device and shared only with your contacts.",
style = MaterialTheme.typography.body1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(10.dp))
Text(
"Display Name",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 3.dp)
)
BasicTextField(
value = displayName,
onValueChange = { displayName = it },
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(5.dp))
.padding(8.dp)
.navigationBarsWithImePadding(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
val errorText = if(!isValidDisplayName(displayName)) "Display name cannot contain whitespace." else ""
Text(
errorText,
fontSize = 15.sp,
color = MaterialTheme.colors.error
)
Spacer(Modifier.height(3.dp))
Text(
"Full Name (Optional)",
style = MaterialTheme.typography.h6,
color = MaterialTheme.colors.onBackground,
modifier = Modifier.padding(bottom = 5.dp)
)
BasicTextField(
value = fullName,
onValueChange = { fullName = it },
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colors.secondary)
.height(40.dp)
.clip(RoundedCornerShape(3.dp))
.padding(8.dp)
.navigationBarsWithImePadding(),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true
)
Spacer(Modifier.height(20.dp))
Button(onClick = {
withApi {
val user = chatModel.controller.apiCreateActiveUser(
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,97 +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.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
import java.util.*
@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)
}
}
)
}
}
@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)
}
}
}
}
@@ -99,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) {
@@ -134,26 +148,42 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
}
}
@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)
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)
}
}
}
}
@ExperimentalAnimatedInsets
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
@@ -181,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,9 +1,9 @@
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
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.CIDirection
import chat.simplex.app.model.ChatItem
import chat.simplex.app.ui.theme.HighOrLowlight
@@ -14,7 +14,7 @@ fun CIMetaView(chatItem: ChatItem) {
Text(
chatItem.timestampText,
color = HighOrLowlight,
style = MaterialTheme.typography.body2
fontSize = 14.sp
)
}
@@ -1,32 +1,74 @@
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.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
@Composable
fun ChatItemView(chatItem: ChatItem) {
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)
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)
}
}
}
@@ -35,9 +77,12 @@ fun ChatItemView(chatItem: ChatItem) {
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,76 +1,87 @@
package chat.simplex.app.views.chat.item
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.Surface
import androidx.compose.foundation.text.ClickableText
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.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 kotlinx.datetime.Clock
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.unit.sp
import chat.simplex.app.model.*
// TODO move to theme
val SentColorLight = Color(0x1E45B8FF)
val ReceivedColorLight = Color(0x1EF1F0F5)
val reserveTimestampStyle = SpanStyle(color = Color.Transparent)
val boldFont = SpanStyle(fontWeight = FontWeight.Medium)
fun appendGroupMember(b: AnnotatedString.Builder, chatItem: ChatItem, groupMemberBold: Boolean) {
if (chatItem.chatDir is CIDirection.GroupRcv) {
val name = chatItem.chatDir.groupMember.memberProfile.displayName
if (groupMemberBold) b.withStyle(boldFont) { append(name) }
else b.append(name)
b.append(": ")
}
}
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 TextItemView(chatItem: ChatItem) {
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)
) {
Column {
Text(text = chatItem.content.text)
CIMetaView(chatItem)
fun MarkdownText (
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,
senderBold: Boolean = false,
modifier: Modifier = Modifier
) {
if (formattedText == null) {
val annotatedText = buildAnnotatedString {
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 {
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) }
}
} else {
withStyle(ft.format.style) { append(ft.text) }
}
}
}
if (metaText != null) withStyle(reserveTimestampStyle) { append(" $metaText") }
}
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 {
Text(annotatedText, style = style, modifier = modifier, maxLines = maxLines, overflow = overflow)
}
}
}
@Preview
@Composable
fun PreviewTextItemViewSnd() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectSnd(), Clock.System.now(), "hello"
)
)
}
}
@Preview
@Composable
fun PreviewTextItemViewRcv() {
SimpleXTheme {
TextItemView(
chatItem = ChatItem.getSampleData(
1, CIDirection.DirectRcv(), Clock.System.now(), "hello"
)
)
}
}
@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."
)
)
}
}
@@ -0,0 +1,117 @@
package chat.simplex.app.views.chat
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
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)? = null) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
val uriHandler = LocalUriHandler.current
Text("Thank you for installing SimpleX Chat!")
Text(
buildAnnotatedString {
append("You can ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("connect to SimpleX Chat founder")
}
append(".")
},
modifier = Modifier.clickable(onClick = {
uriHandler.openUri(simplexTeamUri)
})
)
Column(
Modifier.padding(top = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text(
"To start a new chat",
style = MaterialTheme.typography.h2
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text("Tap button")
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
modifier = if (addContact != null) Modifier.clickable(onClick = addContact) else Modifier,
)
Text("above, then:")
}
Text(
buildAnnotatedString {
withStyle(bold) { append("Add new contact") }
append(": to create your one-time QR Code for your contact.")
}
)
Text(
buildAnnotatedString {
withStyle(bold) { append("Scan QR code") }
append(": to connect to your contact who shows QR code to you.")
}
)
}
Column(
Modifier.padding(top = 24.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
Text("To connect via link", style = MaterialTheme.typography.h2)
Text("If you received SimpleX Chat invitation link you can open it in your browser:")
Text(
buildAnnotatedString {
append("\uD83D\uDCBB desktop: scan displayed QR code from the app, via ")
withStyle(bold) { append("Scan QR code") }
append(".")
}
)
Text(
buildAnnotatedString {
append("\uD83D\uDCF1 mobile: tap ")
withStyle(bold) { append("Open in mobile app") }
append(", then tap ")
withStyle(bold) { append("Connect") }
append(" in the app.")
}
)
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatHelpLayout() {
SimpleXTheme {
ChatHelpView({})
}
}
@@ -0,0 +1,165 @@
package chat.simplex.app.views.chatlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material.Divider
import androidx.compose.material.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.toMutableStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.helpers.AlertManager
import chat.simplex.app.views.helpers.withApi
import kotlinx.datetime.Clock
@Composable
fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
ChatListNavLinkLayout(
chat = chat,
click = {
if (chat.chatInfo is ChatInfo.ContactRequest) {
contactRequestAlertDialog(chat.chatInfo, chatModel)
} else {
withApi { openChat(chatModel, chat.chatInfo) }
}
}
)
}
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
}
}
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",
onConfirm = {
withApi {
val contact = chatModel.controller.apiAcceptContactRequest(contactRequest.apiId)
if (contact != null) {
val chat = Chat(ChatInfo.Direct(contact), listOf())
chatModel.replaceChat(contactRequest.id, chat)
}
}
},
dismissText = "Reject",
onDismiss = {
withApi {
chatModel.controller.apiRejectContactRequest(contactRequest.apiId)
chatModel.removeChat(contactRequest.id)
}
}
)
}
@Composable
fun ChatListNavLinkLayout(chat: Chat, click: () -> Unit) {
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = click)
.height(88.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
verticalAlignment = Alignment.Top
) {
if (chat.chatInfo is ChatInfo.ContactRequest) {
ContactRequestView(chat)
} else {
ChatPreviewView(chat)
}
}
}
Divider(Modifier.padding(horizontal = 8.dp))
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkDirect() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(
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."
)
),
chatStats = Chat.ChatStats()
),
click = {}
)
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkGroup() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.Group.sampleData,
chatItems = listOf(
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."
)
),
chatStats = Chat.ChatStats()
),
click = {}
)
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewChatListNavLinkContactRequest() {
SimpleXTheme {
ChatListNavLinkLayout(
chat = Chat(
chatInfo = ChatInfo.ContactRequest.sampleData,
chatItems = listOf(),
chatStats = Chat.ChatStats()
),
click = {}
)
}
}
@@ -8,93 +8,128 @@ 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.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.NavOptions
import chat.simplex.app.Pages
import chat.simplex.app.model.Chat
import chat.simplex.app.model.ChatModel
import chat.simplex.app.views.helpers.withApi
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 state: BottomSheetScaffoldState, val scope: CoroutineScope) {
fun expand() = scope.launch { state.bottomSheetState.expand() }
fun collapse() = scope.launch { state.bottomSheetState.collapse() }
fun toggle() = scope.launch {
val s = state.bottomSheetState
if (s.isExpanded) s.collapse() else s.expand()
class ScaffoldController(val scope: CoroutineScope) {
lateinit var state: BottomSheetScaffoldState
val expanded = mutableStateOf(false)
fun expand() {
expanded.value = true
scope.launch { state.bottomSheetState.expand() }
}
fun collapse() {
expanded.value = false
scope.launch { state.bottomSheetState.collapse() }
}
fun toggleSheet() {
if (state.bottomSheetState.isExpanded) collapse() else expand()
}
fun toggleDrawer() = scope.launch {
state.drawerState.apply {
if (isClosed) open() else close()
}
state.drawerState.apply { if (isClosed) open() else close() }
}
}
@ExperimentalMaterialApi
@Composable
fun scaffoldController(): ScaffoldController {
return ScaffoldController(
state = rememberBottomSheetScaffoldState(),
scope = rememberCoroutineScope()
val ctrl = ScaffoldController(scope = rememberCoroutineScope())
val bottomSheetState = rememberBottomSheetState(
BottomSheetValue.Collapsed,
confirmStateChange = {
ctrl.expanded.value = it == BottomSheetValue.Expanded
true
}
)
ctrl.state = rememberBottomSheetScaffoldState(bottomSheetState = bottomSheetState)
return ctrl
}
@DelicateCoroutinesApi
@ExperimentalPermissionsApi
@ExperimentalMaterialApi
@Composable
fun ChatListView(chatModel: ChatModel, nav: NavController) {
fun ChatListView(chatModel: ChatModel) {
val scaffoldCtrl = scaffoldController()
BottomSheetScaffold(
scaffoldState = scaffoldCtrl.state,
topBar = {
ChatListToolbar(
scaffoldCtrl,
settings = { scaffoldCtrl.toggleDrawer() }
)
},
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),
) {
Column(
modifier = Modifier
.padding(vertical = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background)
) {
ChatList(chatModel, nav)
}
if (scaffoldCtrl.state.bottomSheetState.isExpanded) {
Surface(
Modifier
Box {
Column(
modifier = Modifier
.fillMaxSize()
.clickable { scaffoldCtrl.collapse() },
color = Color.Black.copy(alpha = 0.12F)
) {}
.background(MaterialTheme.colors.background)
) {
ChatListToolbar(scaffoldCtrl)
if (chatModel.chats.isNotEmpty()) {
ChatList(chatModel)
} else {
val user = chatModel.currentUser.value
Help(scaffoldCtrl, displayName = user?.profile?.displayName)
}
}
if (scaffoldCtrl.expanded.value) {
Surface(
Modifier
.fillMaxSize()
.clickable { scaffoldCtrl.collapse() },
color = Color.Black.copy(alpha = 0.12F)
) {}
}
}
}
}
@ExperimentalMaterialApi
@Composable
fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit) {
fun Help(scaffoldCtrl: ScaffoldController, displayName: String?) {
Column(
Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Text(
text = if (displayName != null) "Welcome ${displayName}!" else "Welcome!",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
ChatHelpView({ scaffoldCtrl.toggleSheet() })
Row(
Modifier.padding(top = 30.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
"This text is available in settings",
color = MaterialTheme.colors.onBackground
)
Icon(
Icons.Outlined.Settings,
"Settings",
tint = MaterialTheme.colors.onBackground,
modifier = Modifier.clickable(onClick = { scaffoldCtrl.toggleDrawer() })
)
}
}
}
@Composable
fun ChatListToolbar(scaffoldCtrl: ScaffoldController) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
@@ -103,9 +138,9 @@ fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit)
.padding(horizontal = 8.dp)
.height(60.dp)
) {
IconButton(onClick = settings) {
IconButton(onClick = { scaffoldCtrl.toggleDrawer() }) {
Icon(
Icons.Outlined.Settings,
Icons.Outlined.Menu,
"Settings",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
@@ -117,7 +152,7 @@ fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit)
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(5.dp)
)
IconButton(onClick = { newChatSheetCtrl.toggle() }) {
IconButton(onClick = { scaffoldCtrl.toggleSheet() }) {
Icon(
Icons.Outlined.PersonAdd,
"Add Contact",
@@ -128,41 +163,14 @@ fun ChatListToolbar(newChatSheetCtrl: ScaffoldController, settings: () -> Unit)
}
}
@DelicateCoroutinesApi
fun goToChat(chatPreview: Chat, chatModel: ChatModel, navController: NavController) {
withApi {
val cInfo = chatPreview.chatInfo
val chat = chatModel.controller.apiGetChat(cInfo.chatType, cInfo.apiId)
if (chat != null) {
chatModel.chatId.value = cInfo.id
chatModel.chatItems = chat.chatItems.toMutableStateList()
navController.navigate(Pages.Chat.route)
} else {
// TODO show error? or will apiGetChat show it
}
}
}
@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 ->
ChatPreviewView(chat) { goToChat(chat, chatModel, navController) }
ChatListNavLinkView(chat, chatModel)
}
}
}
//@Preview
//@Composable
//fun PreviewChatListView() {
// SimpleXTheme {
// ChatListView(
// chats = listOf(
// Chat()
// ),
//
// )
// }
//}
@@ -1,9 +1,11 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.*
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
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
@@ -11,91 +13,80 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.*
import androidx.compose.ui.unit.sp
import chat.simplex.app.model.Chat
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.item.MarkdownText
import chat.simplex.app.views.helpers.ChatInfoImage
import chat.simplex.app.views.helpers.badgeLayout
import kotlinx.datetime.Clock
@Composable
fun ChatPreviewView(chat: Chat, goToChat: () -> Unit) {
Surface(
border = BorderStroke(0.5.dp, MaterialTheme.colors.secondary),
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = goToChat)
.height(88.dp)
) {
Row(
fun ChatPreviewView(chat: Chat) {
Row {
ChatInfoImage(chat, size = 72.dp)
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.padding(start = 8.dp)
.padding(end = 12.dp),
verticalAlignment = Alignment.Top
) {
ChatInfoImage(chat, size = 72.dp)
Column(modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)) {
Text(
chat.chatInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
)
if (chat.chatItems.count() > 0) {
Text(
chat.chatItems.last().content.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
Column(Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top) {
Text(ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom=5.dp)
)
.weight(1F)
) {
Text(
chat.chatInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold
)
if (chat.chatStats.unreadCount > 0) {
Text(
chat.chatStats.unreadCount.toString(),
color = MaterialTheme.colors.onPrimary,
style = MaterialTheme.typography.body2,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.align(Alignment.End)
.badgeLayout()
.padding(2.dp)
)
}
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
MarkdownText(
ci.content, ci.formattedText, ci.memberDisplayName,
metaText = ci.timestampText,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
val ts = chat.chatItems.lastOrNull()?.timestampText ?: getTimestampText(chat.chatInfo.createdAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
) {
Text(
ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
val n = chat.chatStats.unreadCount
if (n > 0) {
Text(
if (n < 1000) "$n" else "${n / 1000}k",
color = MaterialTheme.colors.onPrimary,
fontSize = 14.sp,
modifier = Modifier
.background(MaterialTheme.colors.primary, shape = CircleShape)
.align(Alignment.End)
.badgeLayout()
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
)
}
}
}
}
@Preview
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun ChatPreviewViewExample() {
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(
chat = Chat(
chatInfo = ChatInfo.Direct.sampleData,
chatItems = listOf(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."
)),
chatStats = Chat.ChatStats()
),
goToChat = {}
)
ChatPreviewView(Chat.sampleData)
}
}
@@ -0,0 +1,52 @@
package chat.simplex.app.views.chatlist
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.Chat
import chat.simplex.app.model.getTimestampText
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.views.helpers.ChatInfoImage
@Composable
fun ContactRequestView(chat: Chat) {
Row {
ChatInfoImage(chat, size = 72.dp)
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1F)
) {
Text(
chat.chatInfo.chatViewName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.primary
)
Text(
"wants to connect to you!",
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
val ts = getTimestampText(chat.chatInfo.createdAt)
Column(
Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.Top
) {
Text(
ts,
color = HighOrLowlight,
style = MaterialTheme.typography.body2,
modifier = Modifier.padding(bottom = 5.dp)
)
}
}
}
@@ -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()
}
}
@@ -0,0 +1,19 @@
package chat.simplex.app.views.helpers
import android.content.*
import androidx.core.content.ContextCompat
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
}
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,8 +1,6 @@
package chat.simplex.app.views.newchat
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.background
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
@@ -17,83 +15,78 @@ 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
.padding(horizontal = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Text(
"Add contact",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
"Show QR code to your contact\nto scan from the app",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 8.dp)
)
QRCode(connReq)
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can share the invitation link via any other channel.")
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 8.dp)
.padding(bottom = 16.dp)
)
SimpleButton("Share invitation link", icon = Icons.Outlined.Share, click = share)
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(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.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))
}
}
}
fun shareText(cxt: Context, text: String) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, text)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
cxt.startActivity(shareIntent)
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
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 = {}
)
}
@@ -1,7 +1,8 @@
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
@@ -12,45 +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(
} catch (e: RuntimeException) {
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!"
)
@@ -63,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!"
)
@@ -72,48 +69,50 @@ suspend fun connectViaUri(chatModel: ChatModel, action: String, uri: Uri) {
@Composable
fun ConnectContactLayout(qrCodeScanner: @Composable () -> Unit, close: () -> Unit) {
Column(
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxSize()
.background(MaterialTheme.colors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
CloseSheetBar(close)
Text(
"Scan QR code",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
"Your chat profile will be sent\nto your contact",
style = MaterialTheme.typography.h2,
textAlign = TextAlign.Center,
modifier = Modifier.padding(bottom = 16.dp)
)
Box (
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) { qrCodeScanner() }
Text(
buildAnnotatedString {
append("If you cannot meet in person, you can ")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
append("scan QR code in the video call")
}
append(", or you can create the invitation link.")
},
textAlign = TextAlign.Center,
style = MaterialTheme.typography.caption,
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 16.dp)
)
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(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)
)
}
}
}
@Preview
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewConnectContactLayout() {
SimpleXTheme {
@@ -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.DarkGray
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
@@ -91,7 +91,7 @@ fun ActionButton(text: String, comment: String, icon: ImageVector, disabled: Boo
.padding(horizontal = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
val tint = if (disabled) DarkGray else MaterialTheme.colors.primary
val tint = if (disabled) HighOrLowlight else MaterialTheme.colors.primary
Icon(icon, text,
tint = tint,
modifier = Modifier
@@ -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()
@@ -0,0 +1,48 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.ChatModel
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.chat.ChatHelpView
@Composable
fun HelpView(chatModel: ChatModel) {
val user = chatModel.currentUser.value
if (user != null) {
HelpLayout(displayName = user.profile.displayName)
}
}
@Composable
fun HelpLayout(displayName: String) {
Column(horizontalAlignment = Alignment.Start) {
Text(
"Welcome $displayName!",
Modifier.padding(bottom = 24.dp),
style = MaterialTheme.typography.h1,
)
ChatHelpView()
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewHelpView() {
SimpleXTheme {
HelpLayout(displayName = "Alice")
}
}
@@ -0,0 +1,91 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.*
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import chat.simplex.app.model.Format
import chat.simplex.app.model.FormatColor
import chat.simplex.app.ui.theme.SimpleXTheme
@Composable
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") }
})
}
}
}
}
@Composable
fun MdSyntax(markdown: String) {
Text(markdown, Modifier
.width(100.dp)
.padding(bottom = 4.dp))
}
@Composable
fun MdFormat(markdown: String, example: String, format: Format) {
Row {
MdSyntax(markdown)
Text(buildAnnotatedString {
withStyle(format.style) { append(example) }
})
}
}
@Composable
fun appendColor(b: AnnotatedString.Builder, s: String, c: FormatColor, after: String) {
b.withStyle(Format.Colored(c).style) { append(s)}
b.append(after)
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewMarkdownHelpView() {
SimpleXTheme {
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 = {},
)
}
}
@@ -1,6 +1,7 @@
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.material.*
@@ -11,25 +12,27 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
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.HighOrLowlight
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) } }
)
}
}
@@ -40,28 +43,33 @@ val simplexTeamUri =
@Composable
fun SettingsLayout(
profile: Profile,
navigate: (String) -> Unit
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Column(
Surface(
Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
// .background(MaterialTheme.colors.background)
.padding(8.dp)
) {
Text(
"Your Settings",
style = MaterialTheme.typography.h1,
color = MaterialTheme.colors.onBackground
)
Spacer(Modifier.height(30.dp))
Column(
Modifier
.fillMaxSize()
.background(MaterialTheme.colors.background)
.padding(8.dp)
.padding(top = 16.dp)
) {
Text(
"Your Settings",
style = MaterialTheme.typography.h1,
modifier = Modifier.padding(start = 8.dp)
)
Spacer(Modifier.height(30.dp))
SettingsSectionView(
content = {
SettingsSectionView(showModal { UserProfileView(it) }, 60.dp) {
Icon(
Icons.Outlined.AccountCircle,
contentDescription = "Avatar Placeholder",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Column {
@@ -69,136 +77,116 @@ fun SettingsLayout(
profile.displayName,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colors.onBackground
)
Text(
profile.fullName,
color = MaterialTheme.colors.onBackground
)
Text(profile.fullName)
}
},
func = { navigate(Pages.UserProfile.route) },
height = 60.dp
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { UserAddressView(it) }) {
Icon(
Icons.Outlined.QrCode,
contentDescription = "Address",
tint = HighOrLowlight,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Your SimpleX contact address",
color = HighOrLowlight
)
},
func = { println("navigate to address") }
)
Spacer(Modifier.height(24.dp))
Text("Your SimpleX contact address")
}
Spacer(Modifier.height(24.dp))
SettingsSectionView(
content = {
SettingsSectionView(showModal { HelpView(it) }) {
Icon(
Icons.Outlined.HelpOutline,
contentDescription = "Help",
tint = MaterialTheme.colors.onBackground,
contentDescription = "Chat help",
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"How to use SimpleX Chat",
color = MaterialTheme.colors.onBackground
Text("How to use SimpleX Chat")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showModal { MarkdownHelpView() }) {
Icon(
Icons.Outlined.TextFormat,
contentDescription = "Markdown help",
)
},
func = { println("navigate to help") }
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
Spacer(Modifier.padding(horizontal = 4.dp))
Text("Markdown in messages")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri(simplexTeamUri) }) {
Icon(
Icons.Outlined.Tag,
contentDescription = "SimpleX Team",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Get help & advice via chat",
"Chat with the founder",
color = MaterialTheme.colors.primary
)
},
func = { uriHandler.openUri(simplexTeamUri) }
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("mailto:chat@simplex.chat") }) {
Icon(
Icons.Outlined.Email,
contentDescription = "Email",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Ask questions via email",
"Send us email",
color = MaterialTheme.colors.primary
)
},
func = { uriHandler.openUri("mailto:chat@simplex.chat") }
)
Spacer(Modifier.height(24.dp))
}
Spacer(Modifier.height(24.dp))
SettingsSectionView(
content = {
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",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Chat console",
color = MaterialTheme.colors.onBackground
)
},
func = { navigate(Pages.Terminal.route) }
)
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(
content = {
Text("Chat console")
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView({ uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }) {
Icon(
painter = painterResource(id = R.drawable.ic_github),
contentDescription = "GitHub",
tint = MaterialTheme.colors.onBackground,
)
Spacer(Modifier.padding(horizontal = 4.dp))
Text(
"Install ",
color = MaterialTheme.colors.onBackground
buildAnnotatedString {
append("Install ")
withStyle(SpanStyle(color = MaterialTheme.colors.primary)) {
append("SimpleX Chat for terminal")
}
}
)
Text(
"SimpleX Chat for terminal",
color = MaterialTheme.colors.primary
)
},
func = { uriHandler.openUri("https://github.com/simplex-chat/simplex-chat") }
)
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(click = {}) {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
}
}
@Composable
fun SettingsSectionView(content: (@Composable () -> Unit), func: () -> Unit, height: Dp = 48.dp) {
Surface(
modifier = Modifier
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
) {
Row(
Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
content.invoke()
}
content.invoke()
}
}
@@ -213,7 +201,8 @@ fun PreviewSettingsLayout() {
SimpleXTheme {
SettingsLayout(
profile = Profile.sampleData,
navigate = {}
showModal = {{}},
showTerminal = {}
)
}
}
@@ -0,0 +1,134 @@
package chat.simplex.app.views.usersettings
import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
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.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
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.*
import chat.simplex.app.views.newchat.QRCode
@Composable
fun UserAddressView(chatModel: ChatModel) {
val cxt = LocalContext.current
UserAddressLayout(
userAddress = chatModel.userAddress.value,
createAddress = {
withApi {
chatModel.userAddress.value = chatModel.controller.apiCreateUserAddress()
}
},
share = { userAddress: String -> shareText(cxt, userAddress) },
deleteAddress = {
AlertManager.shared.showAlertMsg(
title = "Delete address?",
text = "All your contacts will remain connected",
confirmText = "Delete",
onConfirm = {
withApi {
chatModel.controller.apiDeleteUserAddress()
chatModel.userAddress.value = null
}
}
)
}
)
}
@Composable
fun UserAddressLayout(
userAddress: String?,
createAddress: () -> Unit,
share: (String) -> Unit,
deleteAddress: () -> Unit
) {
Column(
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Top
) {
Text(
"Your chat address",
Modifier.padding(bottom = 16.dp),
style = MaterialTheme.typography.h1,
)
Text(
"You can share your address as a link or as a QR code - anybody will be able to connect to you, " +
"and if you later delete it - you won't lose your contacts.",
Modifier.padding(bottom = 12.dp),
)
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.SpaceEvenly
) {
if (userAddress == null) {
SimpleButton("Create address", icon = Icons.Outlined.QrCode, click = createAddress)
} else {
QRCode(userAddress, Modifier.weight(1f, fill = false).aspectRatio(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(vertical = 10.dp)
) {
SimpleButton(
"Share link",
icon = Icons.Outlined.Share,
click = { share(userAddress) })
SimpleButton(
"Delete address",
icon = Icons.Outlined.Delete,
color = Color.Red,
click = deleteAddress
)
}
}
}
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserAddressLayoutNoAddress() {
SimpleXTheme {
UserAddressLayout(
userAddress = null,
createAddress = {},
share = { _ -> },
deleteAddress = {},
)
}
}
@Preview(showBackground = true)
@Preview(
uiMode = Configuration.UI_MODE_NIGHT_YES,
showBackground = true,
name = "Dark Mode"
)
@Composable
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",
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),
@@ -179,14 +167,12 @@ fun UserProfileLayout(
showBackground = true,
name = "Dark Mode"
)
@Composable
fun PreviewUserProfileLayoutEditOff() {
SimpleXTheme {
UserProfileLayout(
profile = Profile.sampleData,
editProfile = false,
back = {},
editProfileOff = {},
editProfileOn = {},
saveProfile = { _, _ -> }
@@ -206,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

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@mipmap/icon_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/white"/>
<foreground android:drawable="@color/white"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="icon_background">#FFFFFF</color>
</resources>
@@ -2,6 +2,6 @@
<resources>
<style name="Theme.SimpleX" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:statusBarColor">@color/purple_700</item>
<item name="android:statusBarColor">@color/black</item>
</style>
</resources>
</resources>
+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'
}
+2
View File
@@ -65,3 +65,5 @@ fastlane/test_output
iOSInjectionProject/
Libraries/
Shared/MyPlayground.playground/*
+2 -1
View File
@@ -18,6 +18,7 @@ struct ContentView: View {
.onAppear {
do {
try apiStartChat()
chatModel.userSMPServers = try getUserSMPServers()
chatModel.chats = try apiGetChats()
} catch {
fatalError("Failed to start or load chats: \(error)")
@@ -36,7 +37,7 @@ struct ContentView: View {
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)
+119 -18
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 {
@@ -97,7 +101,7 @@ final class ChatModel: ObservableObject {
if case .rcvNew = cItem.meta.itemStatus {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
if self.chatId == cInfo.id {
SimpleX.markChatItemRead(cInfo, cItem)
Task { await SimpleX.markChatItemRead(cInfo, cItem) }
}
}
}
@@ -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."
}
}
}
@@ -426,6 +430,11 @@ struct Contact: Identifiable, Decodable, NamedChat {
)
}
struct ContactSubStatus: Decodable {
var contact: Contact
var contactError: ChatError?
}
struct Connection: Decodable {
var connStatus: String
@@ -503,6 +512,11 @@ struct GroupMember: Decodable {
)
}
struct MemberSubError: Decodable {
var member: GroupMember
var memberError: ChatError
}
struct AChatItem: Decodable {
var chatInfo: ChatInfo
var chatItem: ChatItem
@@ -512,21 +526,34 @@ struct ChatItem: Identifiable, Decodable {
var chatDir: CIDirection
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
)
}
}
@@ -556,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(
@@ -569,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 {
@@ -587,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)
@@ -609,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"
}
}
}
@@ -641,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":
@@ -653,7 +711,50 @@ 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")
}
}
}
struct FormattedText: Decodable {
var text: String
var format: Format?
}
enum Format: Decodable {
case bold
case italic
case strikeThrough
case snippet
case secret
case colored(color: FormatColor)
case uri
case email
case phone
}
enum FormatColor: String, Decodable {
case red = "red"
case green = "green"
case blue = "blue"
case yellow = "yellow"
case cyan = "cyan"
case magenta = "magenta"
case black = "black"
case white = "white"
var uiColor: Color {
get {
switch (self) {
case .red: return .red
case .green: return .green
case .blue: return .blue
case .yellow: return .yellow
case .cyan: return .cyan
case .magenta: return .purple
case .black: return .primary
case .white: return .primary
}
}
}
}
+19 -2
View File
@@ -37,7 +37,7 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject {
if content.categoryIdentifier == ntfCategoryContactRequest && response.actionIdentifier == ntfActionAccept,
let chatId = content.userInfo["chatId"] as? String,
case let .contactRequest(contactRequest) = chatModel.getChat(chatId)?.chatInfo {
acceptContactRequest(contactRequest)
Task { await acceptContactRequest(contactRequest) }
} else {
chatModel.chatId = content.targetContentIdentifier
}
@@ -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
+209 -66
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
@@ -114,7 +129,9 @@ enum ChatResponse: Decodable, Error {
case contactSubscribed(contact: Contact)
case contactDisconnected(contact: Contact)
case contactSubError(contact: Contact, chatError: ChatError)
case contactSubSummary(contactSubscriptions: [ContactSubStatus])
case groupSubscribed(groupInfo: GroupInfo)
case memberSubErrors(memberSubErrors: [MemberSubError])
case groupEmpty(groupInfo: GroupInfo)
case userContactLinkSubscribed
case newChatItem(chatItem: AChatItem)
@@ -129,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"
@@ -148,7 +167,9 @@ enum ChatResponse: Decodable, Error {
case .contactSubscribed: return "contactSubscribed"
case .contactDisconnected: return "contactDisconnected"
case .contactSubError: return "contactSubError"
case .contactSubSummary: return "contactSubSummary"
case .groupSubscribed: return "groupSubscribed"
case .memberSubErrors: return "memberSubErrors"
case .groupEmpty: return "groupEmpty"
case .userContactLinkSubscribed: return "userContactLinkSubscribed"
case .newChatItem: return "newChatItem"
@@ -166,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
@@ -185,7 +208,9 @@ enum ChatResponse: Decodable, Error {
case let .contactSubscribed(contact): return String(describing: contact)
case let .contactDisconnected(contact): return String(describing: contact)
case let .contactSubError(contact, chatError): return "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))"
case let .contactSubSummary(contactSubscriptions): return String(describing: contactSubscriptions)
case let .groupSubscribed(groupInfo): return String(describing: groupInfo)
case let .memberSubErrors(memberSubErrors): return String(describing: memberSubErrors)
case let .groupEmpty(groupInfo): return String(describing: groupInfo)
case .userContactLinkSubscribed: return noDetails
case let .newChatItem(chatItem): return String(describing: chatItem)
@@ -232,10 +257,53 @@ enum TerminalItem: Identifiable {
}
}
func chatSendCmd(_ cmd: ChatCommand) throws -> 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)")
@@ -247,13 +315,25 @@ func chatSendCmd(_ cmd: ChatCommand) throws -> ChatResponse {
return resp
}
func chatRecvMsg() throws -> ChatResponse {
chatResponse(chat_recv_msg(getChatCtrl())!)
func chatSendCmd(_ cmd: ChatCommand, bgTask: Bool = true, bgDelay: Double? = nil) async -> ChatResponse {
await withCheckedContinuation { cont in
cont.resume(returning: chatSendCmdSync(cmd, bgTask: bgTask, bgDelay: bgDelay))
}
}
func chatRecvMsg() async -> ChatResponse {
await withCheckedContinuation { cont in
_ = withBGTask(bgDelay: msgDelay) {
let resp = chatResponse(chat_recv_msg(getChatCtrl())!)
cont.resume(returning: resp)
return resp
}
}
}
func apiGetActiveUser() throws -> User? {
let _ = getChatCtrl()
let r = try chatSendCmd(.showActiveUser)
let r = chatSendCmdSync(.showActiveUser)
switch r {
case let .activeUser(user): return user
case .chatCmdError(.error(.noActiveUser)): return nil
@@ -262,43 +342,77 @@ func apiGetActiveUser() throws -> User? {
}
func apiCreateActiveUser(_ p: Profile) throws -> User {
let r = try chatSendCmd(.createActiveUser(profile: p))
let r = chatSendCmdSync(.createActiveUser(profile: p))
if case let .activeUser(user) = r { return user }
throw r
}
func apiStartChat() throws {
let r = try chatSendCmd(.startChat)
let r = chatSendCmdSync(.startChat)
if case .chatStarted = r { return }
throw r
}
func apiGetChats() throws -> [Chat] {
let r = try chatSendCmd(.apiGetChats)
let r = chatSendCmdSync(.apiGetChats)
if case let .apiChats(chats) = r { return chats.map { Chat.init($0) } }
throw r
}
func apiGetChat(type: ChatType, id: Int64) throws -> Chat {
let r = try chatSendCmd(.apiGetChat(type: type, id: id))
func apiGetChat(type: ChatType, id: Int64) async throws -> Chat {
let r = await chatSendCmd(.apiGetChat(type: type, id: id))
if case let .apiChat(chat) = r { return Chat.init(chat) }
throw r
}
func apiSendMessage(type: ChatType, id: Int64, msg: MsgContent) throws -> ChatItem {
let r = try chatSendCmd(.apiSendMessage(type: type, id: id, msg: msg))
if case let .newChatItem(aChatItem) = r { return aChatItem.chatItem }
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 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 = try chatSendCmd(.addContact)
let r = chatSendCmdSync(.addContact, bgTask: false)
if case let .invitation(connReqInvitation) = r { return connReqInvitation }
throw r
}
func apiConnect(connReq: String) throws {
let r = try chatSendCmd(.connect(connReq: connReq))
func apiConnect(connReq: String) async throws {
let r = await chatSendCmd(.connect(connReq: connReq))
switch r {
case .sentConfirmation: return
case .sentInvitation: return
@@ -306,14 +420,14 @@ func apiConnect(connReq: String) throws {
}
}
func apiDeleteChat(type: ChatType, id: Int64) throws {
let r = try chatSendCmd(.apiDeleteChat(type: type, id: id))
func apiDeleteChat(type: ChatType, id: Int64) async throws {
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false)
if case .contactDeleted = r { return }
throw r
}
func apiUpdateProfile(profile: Profile) throws -> Profile? {
let r = try chatSendCmd(.updateProfile(profile: profile))
func apiUpdateProfile(profile: Profile) async throws -> Profile? {
let r = await chatSendCmd(.updateProfile(profile: profile))
switch r {
case .userProfileNoChange: return nil
case let .userProfileUpdated(_, toProfile): return toProfile
@@ -321,20 +435,20 @@ func apiUpdateProfile(profile: Profile) throws -> Profile? {
}
}
func apiCreateUserAddress() throws -> String {
let r = try chatSendCmd(.createMyAddress)
func apiCreateUserAddress() async throws -> String {
let r = await chatSendCmd(.createMyAddress)
if case let .userContactLinkCreated(connReq) = r { return connReq }
throw r
}
func apiDeleteUserAddress() throws {
let r = try chatSendCmd(.deleteMyAddress)
func apiDeleteUserAddress() async throws {
let r = await chatSendCmd(.deleteMyAddress)
if case .userContactLinkDeleted = r { return }
throw r
}
func apiGetUserAddress() throws -> String? {
let r = try chatSendCmd(.showMyAddress)
func apiGetUserAddress() async throws -> String? {
let r = await chatSendCmd(.showMyAddress)
switch r {
case let .userContactLink(connReq):
return connReq
@@ -344,59 +458,59 @@ func apiGetUserAddress() throws -> String? {
}
}
func apiAcceptContactRequest(contactReqId: Int64) throws -> Contact {
let r = try chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
func apiAcceptContactRequest(contactReqId: Int64) async throws -> Contact {
let r = await chatSendCmd(.apiAcceptContact(contactReqId: contactReqId))
if case let .acceptingContactRequest(contact) = r { return contact }
throw r
}
func apiRejectContactRequest(contactReqId: Int64) throws {
let r = try chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
func apiRejectContactRequest(contactReqId: Int64) async throws {
let r = await chatSendCmd(.apiRejectContact(contactReqId: contactReqId))
if case .contactRequestRejected = r { return }
throw r
}
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) throws {
let r = try chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
func apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) async throws {
let r = await chatSendCmd(.apiChatRead(type: type, id: id, itemRange: itemRange))
if case .cmdOk = r { return }
throw r
}
func acceptContactRequest(_ contactRequest: UserContactRequest) {
func acceptContactRequest(_ contactRequest: UserContactRequest) async {
do {
let contact = try apiAcceptContactRequest(contactReqId: contactRequest.apiId)
let contact = try await apiAcceptContactRequest(contactReqId: contactRequest.apiId)
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
ChatModel.shared.replaceChat(contactRequest.id, chat)
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
} catch let error {
logger.error("acceptContactRequest error: \(error.localizedDescription)")
}
}
func rejectContactRequest(_ contactRequest: UserContactRequest) {
func rejectContactRequest(_ contactRequest: UserContactRequest) async {
do {
try apiRejectContactRequest(contactReqId: contactRequest.apiId)
ChatModel.shared.removeChat(contactRequest.id)
try await apiRejectContactRequest(contactReqId: contactRequest.apiId)
DispatchQueue.main.async { ChatModel.shared.removeChat(contactRequest.id) }
} catch let error {
logger.error("rejectContactRequest: \(error.localizedDescription)")
}
}
func markChatRead(_ chat: Chat) {
func markChatRead(_ chat: Chat) async {
do {
let minItemId = chat.chatStats.minUnreadItemId
let itemRange = (minItemId, chat.chatItems.last?.id ?? minItemId)
let cInfo = chat.chatInfo
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
ChatModel.shared.markChatItemsRead(cInfo)
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: itemRange)
DispatchQueue.main.async { ChatModel.shared.markChatItemsRead(cInfo) }
} catch {
logger.error("markChatRead apiChatRead error: \(error.localizedDescription)")
}
}
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) {
func markChatItemRead(_ cInfo: ChatInfo, _ cItem: ChatItem) async {
do {
try apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
ChatModel.shared.markChatItemRead(cInfo, cItem)
try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, itemRange: (cItem.id, cItem.id))
DispatchQueue.main.async { ChatModel.shared.markChatItemRead(cInfo, cItem) }
} catch {
logger.error("markChatItemRead apiChatRead error: \(error.localizedDescription)")
}
@@ -411,7 +525,7 @@ func initializeChat() {
}
class ChatReceiver {
private var receiveLoop: DispatchWorkItem?
private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true
private var _lastMsgTime = Date.now
@@ -424,18 +538,18 @@ class ChatReceiver {
receiveMessages = true
_lastMsgTime = .now
if receiveLoop != nil { return }
let loop = DispatchWorkItem(qos: .default, flags: []) {
while self.receiveMessages {
do {
processReceivedMsg(try chatRecvMsg())
self._lastMsgTime = .now
} catch {
logger.error("ChatReceiver.start chatRecvMsg error: \(error.localizedDescription)")
}
}
receiveLoop = Task { await receiveMsgLoop() }
}
func receiveMsgLoop() async {
let msg = await chatRecvMsg()
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()
}
receiveLoop = loop
DispatchQueue.global().async(execute: loop)
}
func stop() {
@@ -468,20 +582,20 @@ func processReceivedMsg(_ res: ChatResponse) {
chatModel.updateChatInfo(cInfo)
}
case let .contactSubscribed(contact):
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, .connected)
processContactSubscribed(contact)
case let .contactDisconnected(contact):
chatModel.updateContact(contact)
chatModel.updateNetworkStatus(contact, .disconnected)
case let .contactSubError(contact, chatError):
chatModel.updateContact(contact)
var err: String
switch chatError {
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError)
processContactSubError(contact, chatError)
case let .contactSubSummary(contactSubscriptions):
for sub in contactSubscriptions {
if let err = sub.contactError {
processContactSubError(sub.contact, err)
} else {
processContactSubscribed(sub.contact)
}
}
chatModel.updateNetworkStatus(contact, .error(err))
case let .newChatItem(aChatItem):
let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem
@@ -492,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)")
@@ -499,12 +620,30 @@ func processReceivedMsg(_ res: ChatResponse) {
}
}
func processContactSubscribed(_ contact: Contact) {
let m = ChatModel.shared
m.updateContact(contact)
m.updateNetworkStatus(contact, .connected)
}
func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
let m = ChatModel.shared
m.updateContact(contact)
var err: String
switch chatError {
case .errorAgent(agentError: .BROKER(brokerErr: .NETWORK)): err = "network"
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError)
}
m.updateNetworkStatus(contact, .error(err))
}
private struct UserResponse: Decodable {
var user: User?
var error: String?
}
private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
private func chatResponse(_ cjson: UnsafeMutablePointer<CChar>) -> ChatResponse {
let s = String.init(cString: cjson)
let d = s.data(using: .utf8)!
// TODO is there a way to do it without copying the data? e.g:
@@ -528,6 +667,7 @@ private func chatResponse(_ cjson: UnsafePointer<CChar>) -> ChatResponse {
}
json = prettyJSON(j)
}
free(cjson)
return ChatResponse.response(type: type ?? "invalid", json: json ?? s)
}
@@ -542,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!
}
@@ -1,32 +0,0 @@
import Foundation
var greeting = "Hello, playground"
let jsonEncoder = JSONEncoder()
//jsonDecoder.decode(Test.self, from: "{\"name\":\"hello\",\"id\":1}".data(using: .utf8)!)
var a = [1, 2, 3]
a.removeAll(where: { $0 == 1} )
print(a)
let input = "This is a test with the привет 🙂 URL https://www.hackingwithswift.com to be detected."
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector.matches(in: input, options: [], range: NSRange(location: 0, length: input.count))
print(matches)
for match in matches {
guard let range = Range(match.range, in: input) else { continue }
let url = input[range]
print(url)
}
let r = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
print(r.firstMatch(in: "+44(0)7448-736-790", options: [], range: NSRange(location: 0, length: "+44(0)7448-736-790".count)) == nil)
let action: NtfAction? = NtfAction(rawValue: "NTF_ACT_ACCEPT")
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<playground version='5.0' target-platform='ios' buildActiveScheme='true' importAppTypes='true'>
<timeline fileName='timeline.xctimeline'/>
</playground>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Timeline
version = "3.0">
<TimelineItems>
<LoggerValueHistoryTimelineItem
documentLocation = "file:///Users/evgeny/opensource/simplex-chat/simplex-chat/apps/ios/Shared/MyPlayground.playground#CharacterRangeLen=88&amp;CharacterRangeLoc=91&amp;EndingColumnNumber=0&amp;EndingLineNumber=7&amp;StartingColumnNumber=3&amp;StartingLineNumber=6&amp;Timestamp=666087303.155273"
selectedRepresentationIndex = "0"
shouldTrackSuperviewWidth = "NO">
</LoggerValueHistoryTimelineItem>
</TimelineItems>
</Timeline>
+10 -6
View File
@@ -63,12 +63,16 @@ struct ChatInfoView: View {
title: Text("Delete contact?"),
message: Text("Contact and all messages will be deleted"),
primaryButton: .destructive(Text("Delete")) {
do {
try apiDeleteChat(type: .direct, id: contact.apiId)
chatModel.removeChat(contact.id)
showChatInfo = false
} catch let error {
logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
Task {
do {
try await apiDeleteChat(type: .direct, id: contact.apiId)
DispatchQueue.main.async {
chatModel.removeChat(contact.id)
showChatInfo = false
}
} catch let error {
logger.error("ChatInfoView.deleteContactAlert apiDeleteChat error: \(error.localizedDescription)")
}
}
},
secondaryButton: .cancel()
@@ -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,149 +0,0 @@
//
// TextItemView.swift
// SimpleX
//
// Created by Evgeny Poberezkin on 04/02/2022.
// Copyright © 2022 SimpleX Chat. All rights reserved.
//
import SwiftUI
private let emailRegex = try! NSRegularExpression(pattern: "^[a-z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$", options: .caseInsensitive)
private let phoneRegex = try! NSRegularExpression(pattern: "^\\+?[0-9\\.\\(\\)\\-]{7,20}$")
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 linkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
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 messageText(_ chatItem: ChatItem) -> Text {
let s = chatItem.content.text
var res: Text
if s == "" {
res = Text("")
} else {
let parts = s.split(separator: " ")
res = wordToText(parts[0])
var i = 1
while i < parts.count {
res = res + Text(" ") + wordToText(parts[i])
i = i + 1
}
}
if case let .groupRcv(groupMember) = chatItem.chatDir {
let member = Text(groupMember.memberProfile.displayName).font(.headline)
return member + Text(": ") + res
} else {
return res
}
}
private func reserveSpaceForMeta(_ meta: String) -> Text {
Text(" \(meta)")
.font(.caption)
.foregroundColor(.clear)
}
private func wordToText(_ s: String.SubSequence) -> Text {
let str = String(s)
switch true {
case s.starts(with: "http://") || s.starts(with: "https://"):
return linkText(str, prefix: "")
case match(str, emailRegex):
return linkText(str, prefix: "mailto:")
case match(str, phoneRegex):
return linkText(str, prefix: "tel:")
default:
if (s.count > 1) {
switch true {
case s.first == "*" && s.last == "*": return mdText(s).bold()
case s.first == "_" && s.last == "_": return mdText(s).italic()
case s.first == "+" && s.last == "+": return mdText(s).underline()
case s.first == "~" && s.last == "~": return mdText(s).strikethrough()
default: return Text(s)
}
} else {
return Text(s)
}
}
}
private func match(_ s: String, _ regex: NSRegularExpression) -> Bool {
regex.firstMatch(in: s, options: [], range: NSRange(location: 0, length: s.count)) != nil
}
private func linkText(_ s: String, prefix: String) -> Text {
Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + s) as Any,
.foregroundColor: linkColor as Any
]))).underline()
}
private func mdText(_ s: String.SubSequence) -> Text {
Text(s[s.index(s.startIndex, offsetBy: 1)..<s.index(s.endIndex, offsetBy: -1)])
}
private func msgDeliveryError(_ err: String) {
AlertManager.shared.showAlertMsg(
title: "Message delivery error",
message: err
)
}
}
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))
}
}

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