Compare commits

...

131 Commits

Author SHA1 Message Date
spaced4ndy 212f193a4c core: group integrity status types (#3302) 2023-11-02 20:07:14 +04:00
Evgeny Poberezkin 7473da36a6 core: group DAG types (#3286)
* core: group DAG types

* fix tests

* schema, more types

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2023-11-01 17:27:34 +04:00
Evgeny Poberezkin c8c17a2f68 core: fix uri parse to not include trailing punctuation in URIs (#3296)
* core: fix uri parse to not include trailing punctuation in URIs

* simplify
2023-11-01 13:10:19 +00:00
Evgeny Poberezkin 9e8084874f ios: block members (#3248)
* ios: block members (WIP)

* CIBlocked, blocking api

* show item as blocked

* show blocked and merge multiple deleted items

* update block icons

* split sent and received deleted to two categories

* merge chat feature items, refactor CIMergedRange

* merge feature items, two profile images and names on merged items

* ensure range is withing chat items range

* merge group events

* fix/refactor

* make group member changes observable

* exclude some group events from merging

* fix states not updating and other fixes

* load list of members when tapping profile

* refactor

* fix incorrect merging of sent/received marked deleted

* fix incorrect expand/hide on single moderated items without content

* load members list when opening member via item

* comments

* fix member counting in case of name collision
2023-10-31 09:44:57 +00:00
spaced4ndy 07173f7b2f core: add delays to testXFTPMarkToReceive test (#3294) 2023-10-31 10:51:20 +04:00
spaced4ndy 42458a2715 ios, android: process new group link events (#3293) 2023-10-31 10:51:02 +04:00
spaced4ndy f34bbdbd9c core: improve group link protocol (immediately establish group connection without first creating contact) (#3288) 2023-10-30 20:40:20 +04:00
Evgeny Poberezkin 9568279b0f update simplexmq 2023-10-29 18:21:51 +00:00
spaced4ndy a7b5dfb74c android: create new group with incognito membership (#3285) 2023-10-27 09:33:59 +04:00
spaced4ndy 7102723c23 ios: create new group with incognito membership (#3284)
* ios: create new group with incognito membership

* layout

* fix button

* update layout

* layout

* layout

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-26 18:51:45 +04:00
spaced4ndy 4a5fdd3e0e ios, android: show progress indicator on joining group (#3281) 2023-10-26 10:32:11 +04:00
Evgeny Poberezkin 4a8da196ad core: more permissive display name validation, only allow single quotes in CLI for the names with spaces (#3282) 2023-10-25 11:55:06 +01:00
Stanislav Dmitrenko 743597e848 ios: making message text view working better (#3279)
* ios: making message text view working better

* style for ternaries

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-25 09:56:59 +01:00
spaced4ndy b0f55d6de5 core: update simplexmq (check snd queue) (#3280) 2023-10-25 10:45:36 +04:00
Stanislav Dmitrenko 1dcd2760b0 ui: show alert after saving profile with existing name (#3273)
* android, desktop: show alert after saving profile with existing name

* ios: show alert after saving profile with existing name

* return statements

* better way of showing alert

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-24 23:01:47 +01:00
Stanislav Dmitrenko 10f79aae66 android: alert on unsupported file path when sharing (#3265)
* android: alert on unsupported file path when sharing

* update text

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-24 21:39:43 +01:00
Stanislav Dmitrenko b58d61c339 android: delete files after sharing correctly (#3264) 2023-10-24 21:27:58 +01:00
spaced4ndy 239765e482 core: create new group with incognito membership (#3277) 2023-10-24 20:59:06 +04:00
spaced4ndy f8332bac7f core: take chat lock earlier when joining group (#3272) 2023-10-24 18:13:19 +04:00
spaced4ndy ed1eef7362 core: update simplexmq (inv locks) (#3274) 2023-10-24 17:38:16 +04:00
Evgeny Poberezkin 66d8bb94d6 website: downloads page 2023-10-23 21:16:36 +01:00
Evgeny Poberezkin 6eb09625ab website: update copy 2023-10-23 20:53:12 +01:00
Evgeny Poberezkin 0bd59364fd 5.4.0-beta.2: iOS 180, Android 159, Desktop 15 2023-10-22 22:43:23 +01:00
Evgeny Poberezkin 795c54343a android, desktop: reduce browser call logs, check Worker availability directly (#3256) 2023-10-22 20:51:08 +01:00
Evgeny Poberezkin f026a38a75 ios: update core library 2023-10-22 19:22:46 +01:00
Stanislav Dmitrenko 530ec70171 android, desktop: support calls on desktop and moved www dir to different root (#3219)
* android, desktop: support calls on desktop and moved www dir to different root

* add page title, fix links on Android, change timeouts

* using worker in desktop Chrome and Safari

* ui changes

* end call button in app bar

* fix android

* a lot of enhancements

* fix after merge master

* layout

* sound play on call

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-22 18:47:27 +01:00
Era Dorta 1401f56288 cli: update docker image to use ghc 9.6.2 (#3234) 2023-10-22 18:15:15 +01:00
Evgeny Poberezkin d172b0cb6d Merge branch 'stable' 2023-10-22 17:51:30 +01:00
Evgeny Poberezkin 3e0b6826bf ios: 5.3.2 build 179 2023-10-22 17:50:08 +01:00
Evgeny Poberezkin 79275424ea core: 5.4.0.2 2023-10-22 15:06:55 +01:00
Evgeny Poberezkin b25c2e3a09 ui: add 10 minutes SimpleX Lock delay (#3255) 2023-10-22 14:56:51 +01:00
Evgeny Poberezkin 8891314507 core: update simplexmq (fixes ordering issue during message delivery) 2023-10-22 14:19:24 +01:00
Evgeny Poberezkin 5c57987e9f add smp11, 12 and 14 to preset servers 2023-10-22 13:58:51 +01:00
spaced4ndy 87d84cfccc core: filter connection plan query results by user_id (#3251) 2023-10-21 19:13:32 +04:00
spaced4ndy c090b68bdd ios, android: ask to notify contact or not on contact deletion (#3247) 2023-10-19 19:52:59 +04:00
Evgeny Poberezkin 2219cea026 core: fix type for JSON 2023-10-18 21:15:37 +01:00
spaced4ndy 852e77b1d9 android: connect plan (#3242) 2023-10-18 16:58:33 +04:00
Evgeny Poberezkin 706d6bf65b core, ui: prevent old sent items re-added to chat, and "new" status overriding "sent" (#3246)
* core, ui: prevent old sent items re-added to chat, and "new" status overriding "sent"

* clear item statuses when changing current chat

* remove iOS hack

* remote state/published from chatItemStatuses
2023-10-18 11:23:35 +01:00
Evgeny Poberezkin a02886ca5d core: fix editing and status changes removing reactions from view (#3245)
* core: fix editing and status changes removing reactions from view

* refactor

* refactor 2

* case
2023-10-18 10:19:24 +01:00
Evgeny Poberezkin 29c8ab7c9b ui: file & media preference for groups (#3243) 2023-10-17 18:05:16 +01:00
spaced4ndy d8d47d706d ios: connection plan improvements; remove browser mode for simplex links (#3237) 2023-10-17 12:56:12 +04:00
spaced4ndy 99c458406f android: remove browser mode for simplex links (#3239) 2023-10-17 11:44:35 +04:00
spaced4ndy e4c8386f3f core: replace simplex:/ with simplex.chat links in view; remove trustedUri flag from simplex links markdown format (#3235) 2023-10-16 19:23:38 +04:00
spaced4ndy 9ed31261e1 core: check saved links and hashes by both connection request uri schemas for connection plan (#3233) 2023-10-16 16:16:03 +04:00
spaced4ndy 4b6df43e97 core: confirm to reconnect via address plan (#3212)
* core: confirm to reconnect plan

* rework query to prefer connections with contacts
2023-10-16 16:10:56 +04:00
Evgeny Poberezkin 43b67ba157 ui: set local file encryption in the core (#3227) 2023-10-15 20:58:39 +01:00
Evgeny Poberezkin e6b0983c3e Merge branch 'stable' 2023-10-15 18:52:46 +01:00
Evgeny Poberezkin c2a320640b core: local encryption for auto-received inline files (e.g. small voice messages) (#3224)
* core: local encryption for auto-received inline files

* update view, test
2023-10-15 18:16:12 +01:00
Evgeny Poberezkin 838751fe78 ios: fix Protect screen not hiding message previews (#3226) 2023-10-15 18:12:13 +01:00
Evgeny Poberezkin a35dc263b7 website: remove address from connect page 2023-10-13 22:28:55 +01:00
spaced4ndy c609303348 ios: connect plan (#3205)
* ios: connect plan

* improvements

* wording

* fixes

* rework to use dismissAllSheets with callback

* rework

* update texts

* Update apps/ios/Shared/Views/NewChat/NewChatButton.swift

* Update apps/ios/Shared/Views/NewChat/NewChatButton.swift

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-13 19:19:00 +04:00
Evgeny Poberezkin 07047a3ef3 ios: fix pattern match 2023-10-13 14:29:48 +01:00
Evgeny Poberezkin ab290fb068 core: track network statuses, use in commands/events (#3211)
* core: track network statuses, use in commands/events

* ui types, test

* remove comment
2023-10-13 11:51:01 +01:00
Stanislav Dmitrenko 675fc19745 rfc: desktop calls (#3208)
* rfc: desktop calls

* errors

* html

* link

* screen sharing

* additions

* addition

* correction

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-13 00:05:06 +01:00
Stanislav Dmitrenko 8ffe1c23c1 android, desktop: better handling of parallel updates of chats (#3204)
* android, desktop: better handling of parallel updates of chats

* one more case
2023-10-12 13:19:57 +01:00
spaced4ndy 5d078bec53 tests: simplify testMemberContactInvitedConnectionReplaced (for stable) 2023-10-12 12:21:03 +04:00
spaced4ndy b956988a83 tests: simplify testMemberContactInvitedConnectionReplaced (for stable) 2023-10-12 12:20:31 +04:00
spaced4ndy 247f2c9e61 tests: modify testMemberContactInvitedConnectionReplaced to not rely on chat item order, print output (#3198) 2023-10-12 11:55:38 +04:00
spaced4ndy 7b488c7f1b tests: improve tests (#3203) 2023-10-12 11:52:14 +04:00
Evgeny Poberezkin 4df8ea2e78 ui: update types for notification and member settings (#3201) 2023-10-11 23:07:05 +01:00
Evgeny Poberezkin 8ff6b392c2 core: rename "reference" to "mention" 2023-10-11 21:15:31 +01:00
Evgeny Poberezkin bca9473d77 core: settings to hide member messages, to show only reply (and mention) notifications (#3190)
* core: settings to hide member messages, to show only reply (and mention) notifications

* change type for showMessages

* commands for member settings

* member and notification settings

* test

* take member settings into account when showing messages and notifications

* fix to show sent messages

* store blocked items

* types

* rename to MFMentions
2023-10-11 19:10:38 +01:00
spaced4ndy b03fe183bb tests: modify testMemberContactInvitedConnectionReplaced to not rely on chat item order, print output (#3198) 2023-10-11 15:26:44 +04:00
Evgeny Poberezkin 4ecf94dfad core: move CLI notifications and active chat to view layer (for remote CLI) (#3196)
* core: move CLI notifications to view layer (to allow notifications in remote CLI)

* remove unused

* refactor activeTo

* move activeTo to ChatTerminal

* refactor

* move back

* remove extension
2023-10-11 09:50:11 +01:00
spaced4ndy a67b79952b core: connection plan api; check connection plan before connecting in terminal api (#3176) 2023-10-10 21:19:04 +04:00
Evgeny Poberezkin eb5081624a Merge branch 'stable' 2023-10-09 19:52:30 +01:00
Evgeny Poberezkin 86c2f29920 5.3.2: ios 178, android 157, desktop 14 2023-10-09 18:30:59 +01:00
Evgeny Poberezkin dffbd32c76 Merge branch 'stable' 2023-10-09 18:04:11 +01:00
Evgeny Poberezkin 3ddf7b2680 ios: close database connections when app is terminating (#3188)
* ios: close database connections when app is terminating

* update

* remove ()

* close when suspended too

* additional check

* fix

* refactore

* reset "terminating" flag
2023-10-09 18:03:03 +01:00
Evgeny Poberezkin c0e22d74c4 core: 5.4.0.1 2023-10-09 17:30:48 +01:00
Stanislav Dmitrenko d764b3485a desktop (windows): Github action for packaging (#3167)
* desktop (windows): Github action for packaging

* env

* path changes
2023-10-09 17:10:47 +01:00
Evgeny Poberezkin 09e5798d59 ios: correctly parse json responses (#3193) 2023-10-09 16:56:42 +01:00
Evgeny Poberezkin f31b4e4632 Merge branch 'stable' 2023-10-09 16:00:24 +01:00
spaced4ndy d72c04682f Merge pull request #3174 from simplex-chat/contact-merge-improvements
contact merge improvements
2023-10-09 18:05:46 +04:00
Evgeny Poberezkin 20995c6912 core: 5.3.2.0 2023-10-09 14:35:44 +01:00
Moritz Angermann 73b3ea3648 Drop entropy patch (#3191)
* Drop entropy patch

We don't need the patch anymore. We can set -fDoNotGetEntropy these days to achieve the same.

* remove entropy.patch

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-09 14:04:29 +01:00
Evgeny Poberezkin bc26c23d58 fix MobileTests (add single field JSON tag) 2023-10-09 10:35:13 +01:00
spaced4ndy 4a581cb292 Merge branch 'master' into contact-merge-improvements 2023-10-09 10:21:54 +04:00
spaced4ndy ab46cbc5dd core: relax contact probing: don't send probe hashes to new contacts except group link hosts; still send probe hashes to group members (#3180) 2023-10-09 09:46:58 +04:00
Evgeny Poberezkin 985b9837c3 core: api to close database connection (#3186) 2023-10-08 16:22:53 +01:00
Moritz Angermann d50c7ad7f6 bump hackage (#3185) 2023-10-08 08:37:28 +01:00
Evgeny Poberezkin 82faaebb33 Merge branch 'stable' 2023-10-08 08:17:05 +01:00
Stanislav Dmitrenko 76fb5b6dca android: fix lock not showing (#3181)
* android: fix lock not showing

* better fix
2023-10-07 19:00:40 +01:00
Evgeny Poberezkin b05a45f559 core: updated simplexmq (add tag to platform-specific sum type encoding that uses single field objects with swift/iOS) 2023-10-07 12:32:39 +01:00
spaced4ndy c738c6c522 Merge branch 'master' into contact-merge-improvements 2023-10-06 14:38:56 +04:00
spaced4ndy 1be70169ba docs: contact merge issues rfc (#3179) 2023-10-06 14:27:13 +04:00
Stanislav Dmitrenko 34e1e44338 android, desktop: profile names (remove full name) (#3177)
* desktop, android: profile names (remove full name)

* rename back

* disallow spaces only in names

* ios: disallow spaces only in names

* changes
2023-10-05 21:49:18 +01:00
spaced4ndy 303d0eedf5 core: merge new contacts with existing contacts and group members (#3173) 2023-10-04 20:58:22 +04:00
Evgeny Poberezkin 0d8558a6d0 ios: profile names (remove full name) (#3168)
* ios: profile names (remove full name)

* create/update groups

* focus display name
2023-10-04 17:45:39 +01:00
Stanislav Dmitrenko 91fc238ddc desktop: libs refactoring (#3169)
* desktop: libs refactoring

* mac fix

* windows fix

* .gitignore

* unused lines

* desktop (windows): adapting Windows build to new libs

* removed unused code

---------

Co-authored-by: avently <avently@local>
2023-10-04 17:06:23 +01:00
Stanislav Dmitrenko cc95fa6b30 desktop: paste files/images to attach to message (#3165)
* desktop: paste files/images to attach to message

* Windows

* copy files inside the app

* change

* encrypted files support

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-03 13:46:17 +01:00
Evgeny Poberezkin 38be27271f core: profile names with spaces (#3105)
* core: profile names with spaces

* test

* more test

* update name validation, export C API

* refactor

* validate name when creating profile in CLI

* validate display name in all APIs when it is changed
2023-10-02 21:56:11 +01:00
zenobit da2a94578a typo (#3121) 2023-10-02 21:21:26 +01:00
Stanislav Dmitrenko 77db70139b windows: shortcut for installator (#3156)
Co-authored-by: avently <avently@local>
2023-10-02 17:25:49 +01:00
Stanislav Dmitrenko fdf3da73aa desktop: making chat list item to have a hover effect (#3162)
* desktop: making chat list item to have a hover effect

* changes

* fix

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-02 17:23:38 +01:00
Stanislav Dmitrenko 0d93dab692 android, desktop: added useful logs (#3163) 2023-10-02 15:46:30 +01:00
spaced4ndy d4cbef1ba1 core: notify about contact deletion only if contact is ready, catch errors (#3160) 2023-10-02 16:29:13 +04:00
Evgeny Poberezkin 8545a1e8f9 ci: make docs update rebuild website 2023-10-01 20:46:30 +01:00
Evgeny Poberezkin 157ea59ebb docs: update downloads page 2023-10-01 18:53:58 +01:00
Evgeny Poberezkin 7231201c3c v5.4-beta.0: ios 176, android 156, desktop 12
* desktop: v5.4-beta.0 build 12

* v5.4-beta.0: ios 176, android 156, desktop 12
2023-10-01 18:31:52 +01:00
Stanislav Dmitrenko 695d47da2d desktop: Windows build (#3143)
* desktop: Windows build

* temp

* temp

* new way of libs loading

* new way of libs loading

* Revert "new way of libs loading"

This reverts commit 8632f8a8f7.

* made VLC working on Windows

* unused lib

* scripts

* updated script

* fix path

* fix lib loading

* fix lib loading

* packaging options

* different file manager implementation on Windows

---------

Co-authored-by: Avently <avently.local>
Co-authored-by: avently <avently@local>
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-01 13:33:15 +01:00
Evgeny Poberezkin 968d8e9c34 core: 5.4.0.0 2023-10-01 13:19:32 +01:00
Stanislav Dmitrenko d72c9a6de0 desktop: ability to always show terminal view (#3074)
* desktop: ability to always show terminal view

* only show toggle with dev tools enabled

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-10-01 12:12:17 +01:00
Alexander Bondarenko 5ce388522e Move toView and withStore* to a common module (#3147) 2023-09-29 15:50:20 +01:00
Evgeny Poberezkin 70a65e8969 core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient (#3146)
* core: close stores before import/delete/encryption operations to make compatible with windows, make encryption more resilient

* remove file names

* do not remove files if already removed
2023-09-29 13:09:48 +01:00
Evgeny Poberezkin 1d34500fba core: revert stop/close changes made for Windows (#3145)
* Revert "core: return error response when wrong passphrase is passed to start"

This reverts commit ea319313f1.

* Revert "core: support closing/re-opening store on chat stop/start (#3132)"

This reverts commit 3c7fc6b0ee.
2023-09-29 11:14:10 +01:00
spaced4ndy bc7baf560b core: filter out connections of deleted contacts and group members on subscribe (#3144) 2023-09-29 11:24:16 +04:00
Stanislav Dmitrenko c1854b7d50 desktop: fix script for building the lib (#3141) 2023-09-28 11:39:43 +01:00
spaced4ndy 682dfe503c android, desktop: notify contact about contact deletion (#3139)
Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-09-28 13:52:43 +04:00
spaced4ndy 957f3b3eb0 core: delete unused contact silently (#3140) 2023-09-28 13:16:03 +04:00
Evgeny Poberezkin dea96df27b docs: update join team 2023-09-28 09:26:54 +01:00
Evgeny Poberezkin 942e5eb8c4 docs: update branches 2023-09-27 22:19:20 +01:00
Evgeny Poberezkin ea319313f1 core: return error response when wrong passphrase is passed to start 2023-09-27 21:15:19 +01:00
spaced4ndy bbe329072e ios: notify contact about contact deletion (#3135) 2023-09-27 20:07:32 +04:00
spaced4ndy c64d1e8361 core: notify contact about contact deletion (#3131) 2023-09-27 19:36:13 +04:00
Stanislav Dmitrenko 7e17ed7b1b desktop (mac): removing rpaths (#3136)
* desktop (mac): removing rpaths

* one more lib

* added check for dir existence in linking

* new line

* patching libapp on mac
2023-09-27 15:34:46 +01:00
Evgeny Poberezkin 3c7fc6b0ee core: support closing/re-opening store on chat stop/start (#3132)
* core: support closing/re-opening store on chat stop/start

* update .nix refs

* kotlin: types

* add test

* update simplexmq
2023-09-27 15:26:03 +01:00
Stanislav Dmitrenko 8709ad6ff3 desktop: enhanced video player + inline player (#3130)
* desktop: enhanced video player + inline player

* simplify

* simplify

* removed unused code

* follow up

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-09-27 10:19:48 +01:00
Evgeny Poberezkin 50d624ef6b blog: move image 2023-09-25 20:09:08 +01:00
Evgeny Poberezkin 11e448267d website: update post, downloads page 2023-09-25 18:12:47 +01:00
Evgeny Poberezkin aacf741ef5 ci: exclude -fdroid tags from github builds 2023-09-25 16:51:10 +01:00
spaced4ndy 420d80ad6c 5.3.1: android 154, ios 174, desktop 11
* 5.3.1

* 5.3.1: ios 174, desktop 11

---------

Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com>
2023-09-25 16:41:53 +01:00
spaced4ndy 343131c64e core: 5.3.1.0 2023-09-25 17:43:03 +04:00
spaced4ndy 9b107fbdeb core: fix invited member contact (do not display invitation context in UI) (#3122) 2023-09-25 16:39:27 +04:00
spaced4ndy 60d13e258e ios, android: show rcv integrity error items based on developer tools default (#3123) 2023-09-25 16:38:48 +04:00
Evgeny Poberezkin 4f42c2b0d8 blog: v5.3 announcement (#3093)
* blog: v5.3 announcement draft

* update

* update post

* add images and previews

* website: add imageWide property

* website: add .float-to-left class

* update

* update images

* update readme

* fix typo

---------

Co-authored-by: M Sarmad Qadeer <MSarmadQadeer@gmail.com>
2023-09-25 09:32:27 +01:00
Stanislav Dmitrenko 48ae1111a6 desktop: fix lib copy (#3120) 2023-09-25 09:30:44 +01:00
Stanislav Dmitrenko 76dbe32cfc desktop: fix JNI (#3119) 2023-09-25 09:30:21 +01:00
Evgeny Poberezkin 120f42cbba readme: update languages 2023-09-23 18:37:03 +01:00
Evgeny Poberezkin 5f46433f40 docs: update translations in readme 2023-09-23 18:33:54 +01:00
Evgeny Poberezkin 7b71078c76 update downloads page 2023-09-23 17:36:44 +01:00
274 changed files with 11208 additions and 3606 deletions
+34
View File
@@ -8,6 +8,7 @@ on:
- users - users
tags: tags:
- "v*" - "v*"
- "!*-fdroid"
pull_request: pull_request:
jobs: jobs:
@@ -292,4 +293,37 @@ jobs:
body: | body: |
${{ steps.windows_build.outputs.bin_hash }} ${{ steps.windows_build.outputs.bin_hash }}
- name: Windows build desktop
id: windows_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
env:
SIMPLEX_CI_REPO_URL: ${{ secrets.SIMPLEX_CI_REPO_URL }}
shell: bash
run: |
scripts/desktop/build-lib-windows.sh
cd apps/multiplatform
./gradlew packageMsi
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-512\(${{ matrix.desktop_asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Windows upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.windows_desktop_build.outputs.package_path }}
asset_name: ${{ matrix.desktop_asset_name }}
tag: ${{ github.ref }}
- name: Windows update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.windows_desktop_build.outputs.package_hash }}
# Windows / # Windows /
+1
View File
@@ -9,6 +9,7 @@ on:
- website/** - website/**
- images/** - images/**
- blog/** - blog/**
- docs/**
- .github/workflows/web.yml - .github/workflows/web.yml
jobs: jobs:
+4 -4
View File
@@ -8,12 +8,12 @@ RUN a=$(arch); curl https://downloads.haskell.org/~ghcup/$a-linux-ghcup -o /usr/
chmod +x /usr/bin/ghcup chmod +x /usr/bin/ghcup
# Install ghc # Install ghc
RUN ghcup install ghc 8.10.7 RUN ghcup install ghc 9.6.2
# Install cabal # Install cabal
RUN ghcup install cabal RUN ghcup install cabal 3.10.1.0
# Set both as default # Set both as default
RUN ghcup set ghc 8.10.7 && \ RUN ghcup set ghc 9.6.2 && \
ghcup set cabal ghcup set cabal 3.10.1.0
COPY . /project COPY . /project
WORKDIR /project WORKDIR /project
+24 -21
View File
@@ -119,19 +119,22 @@ Join our translators to help SimpleX grow!
|locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs| |locale|language |contributor|[Android](https://play.google.com/store/apps/details?id=chat.simplex.app) and [iOS](https://apps.apple.com/us/app/simplex-chat/id1605771084)|[website](https://simplex.chat)|Github docs|
|:----:|:-------:|:---------:|:---------:|:---------:|:---------:| |:----:|:-------:|:---------:|:---------:|:---------:|:---------:|
|🇬🇧 en|English | |✓|✓|✓|✓| |🇬🇧 en|English | |✓|✓|✓|✓|
|ar|العربية |[jermanuts](https://github.com/jermanuts)||[![website](https://hosted.weblate.org/widgets/simplex-chat/ar/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ar/)|| |ar|العربية |[jermanuts](https://github.com/jermanuts)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/ar/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ar/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/ar/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ar/)||
|🇧🇬 bg|Български |-|[![android app](https://hosted.weblate.org/widgets/simplex-chat/bg/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>-||| |🇧🇬 bg|Български | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/bg/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/bg/)<br>[![ios app](https://hosted.weblate.org/widget/simplex-chat/ios/bg/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/bg/)|||
|🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/cs/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/cs/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/cs/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)| |🇨🇿 cs|Čeština |[zen0bit](https://github.com/zen0bit)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/cs/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/cs/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/cs/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/cs/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/cs/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/cs/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/cs)|
|🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/de/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/de/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/de/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/de/)|| |🇩🇪 de|Deutsch |[mlanp](https://github.com/mlanp)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/de/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/de/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/de/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/de/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/de/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/de/)||
|🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/es/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/es/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/es/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/es/)|| |🇪🇸 es|Español |[Mateyhv](https://github.com/Mateyhv)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/es/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/es/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/es/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/es/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/es/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/es/)||
|🇫🇮 fi|Suomi | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/fi/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fi/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fi/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fi/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fi/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fi/)||
|🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)| |🇫🇷 fr|Français |[ishi_sama](https://github.com/ishi-sama)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/fr/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/fr/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/fr/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/fr/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/fr/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/fr/)|[](https://github.com/simplex-chat/simplex-chat/tree/master/docs/lang/fr)|
|🇮🇱 he|עִברִית | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/he/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/he/)<br>-|||
|🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)|| |🇮🇹 it|Italiano |[unbranched](https://github.com/unbranched)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/it/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/it/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/it/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/it/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/it/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/it/)||
|🇯🇵 ja|Japanese ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ja/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)||| |🇯🇵 ja|日本語 | |[![android app](https://hosted.weblate.org/widgets/simplex-chat/ja/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ja/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ja/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ja/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/ja/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/ja/)||
|🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)|| |🇳🇱 nl|Nederlands|[mika-nl](https://github.com/mika-nl)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/nl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/nl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/nl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/nl/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/nl/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/nl/)||
|🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/pl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/pl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)||| |🇵🇱 pl|Polski |[BxOxSxS](https://github.com/BxOxSxS)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/pl/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pl/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/pl/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/pl/)|||
|🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)|| |🇧🇷 pt-BR|Português||[![android app](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/)<br>-|[![website](https://hosted.weblate.org/widgets/simplex-chat/pt_BR/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/)||
|🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)||| |🇷🇺 ru|Русский ||[![android app](https://hosted.weblate.org/widgets/simplex-chat/ru/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/ru/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/ru/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/ru/)|||
|🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/th/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/th/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/th/)||| |🇹🇭 th|ภาษาไทย |[titapa-punpun](https://github.com/titapa-punpun)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/th/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/th/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/th/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/th/)|||
|🇺🇦 uk|Українська| |[![android app](https://hosted.weblate.org/widgets/simplex-chat/uk/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/uk/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/uk/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/uk/)|[![website](https://hosted.weblate.org/widgets/simplex-chat/uk/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/uk/)||
|🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)|| |🇨🇳 zh-CHS|简体中文|[sith-on-mars](https://github.com/sith-on-mars)<br><br>[Float-hu](https://github.com/Float-hu)|[![android app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/android/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/)<br>[![ios app](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/ios/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/)<br>&nbsp;|<br><br>[![website](https://hosted.weblate.org/widgets/simplex-chat/zh_Hans/website/svg-badge.svg)](https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/)||
Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us! Languages in progress: Arabic, Japanese, Korean, Portuguese and [others](https://hosted.weblate.org/projects/simplex-chat/#languages). We will be adding more languages as some of the already added are completed please suggest new languages, review the [translation guide](./docs/TRANSLATIONS.md) and get in touch with us!
@@ -227,24 +230,18 @@ You can use SimpleX with your own servers and still communicate with people usin
## News and updates ## News and updates
Recent updates: Recent and important updates:
[July 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md). [Sep 25, 2023. SimpleX Chat v5.3 released: desktop app, local file encryption, improved groups and directory service](./blog/20230925-simplex-chat-v5-3-desktop-app-local-file-encryption-directory-service.md).
[Jul 22, 2023. SimpleX Chat: v5.2 released with message delivery receipts](./blog/20230722-simplex-chat-v5-2-message-delivery-receipts.md).
[May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md). [May 23, 2023. SimpleX Chat: v5.1 released with message reactions and self-destruct passcode](./blog/20230523-simplex-chat-v5-1-message-reactions-self-destruct-passcode.md).
[Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md). [Apr 22, 2023. SimpleX Chat: vision and funding, v5.0 released with videos and files up to 1gb](./blog/20230422-simplex-chat-vision-funding-v5-videos-files-passcode.md).
[Mar 28, 2023. v4.6 released - with Android 8+ and ARMv7a support, hidden profiles, community moderation, improved audio/video calls and reduced battery usage](./blog/20230328-simplex-chat-v4-6-hidden-profiles.md).
[Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md). [Mar 1, 2023. SimpleX File Transfer Protocol send large files efficiently, privately and securely, soon to be integrated into SimpleX Chat apps.](./blog/20230301-simplex-file-transfer-protocol.md).
[Feb 4, 2023. v4.5 released - with multiple user profiles, message draft, transport isolation and Italian interface](./blog/20230204-simplex-chat-v4-5-user-chat-profiles.md).
[Jan 3, 2023. v4.4 released - with disappearing messages, "live" messages, connection security verifications, GIFs and stickers and with French interface language](./blog/20230103-simplex-chat-v4.4-disappearing-messages.md).
[Dec 6, 2022. November reviews and v4.3 released - with instant voice messages, irreversible deletion of sent messages and improved server configuration](./blog/20221206-simplex-chat-v4.3-voice-messages.md).
[Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md). [Nov 8, 2022. Security audit by Trail of Bits, the new website and v4.2 released](./blog/20221108-simplex-chat-v4.2-security-audit-new-website.md).
[Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md). [Sep 28, 2022. v4.0: encrypted local chat database and many other changes](./blog/20220928-simplex-chat-v4-encrypted-database.md).
@@ -290,18 +287,20 @@ What is already implemented:
3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message. 3. [Double ratchet](./docs/GLOSSARY.md#double-ratchet-algorithm) end-to-end encryption in each conversation between two users (or group members). This is the same algorithm that is used in Signal and many other messaging apps; it provides OTR messaging with [forward secrecy](./docs/GLOSSARY.md#forward-secrecy) (each message is encrypted by its own ephemeral key) and [break-in recovery](./docs/GLOSSARY.md#post-compromise-security) (the keys are frequently re-negotiated as part of the message exchange). Two pairs of Curve448 keys are used for the initial [key agreement](./docs/GLOSSARY.md#key-agreement-protocol), initiating party passes these keys via the connection link, accepting side - in the header of the confirmation message.
4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well). 4. Additional layer of encryption using NaCL cryptobox for the messages delivered from the server to the recipient. This layer avoids having any ciphertext in common between sent and received traffic of the server inside TLS (and there are no identifiers in common as well).
5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks. 5. Several levels of [content padding](./docs/GLOSSARY.md#message-padding) to frustrate message size attacks.
6. Starting from v2 of SMP protocol (the current version is v4) all message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed. 6. All message metadata, including the time when the message was received by the server (rounded to a second) is sent to the recipients inside an encrypted envelope, so even if TLS is compromised it cannot be observed.
7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448. 7. Only TLS 1.2/1.3 are allowed for client-server connections, limited to cryptographic algorithms: CHACHA20POLY1305_SHA256, Ed25519/Ed448, Curve25519/Curve448.
8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key. 8. To protect against replay attacks SimpleX servers require [tlsunique channel binding](https://www.rfc-editor.org/rfc/rfc5929.html) as session ID in each client command signed with per-queue ephemeral key.
9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details. 9. To protect your IP address all SimpleX Chat clients support accessing messaging servers via Tor - see [v3.1 release announcement](./blog/20220808-simplex-chat-v3.1-chat-groups.md) for more details.
10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings. 10. Local database encryption with passphrase - your contacts, groups and all sent and received messages are stored encrypted. If you used SimpleX Chat before v4.0 you need to enable the encryption via the app settings.
11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections. 11. Transport isolation - different TCP connections and Tor circuits are used for traffic of different user profiles, optionally - for different contacts and group member connections.
12. Manual messaging queue rotations to move conversation to another SMP relay. 12. Manual messaging queue rotations to move conversation to another SMP relay.
13. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
14. Local files encryption, except videos (to be added later).
We plan to add: We plan to add:
1. Local files encryption. Currently the images and files you send and receive are stored in the app unencrypted, you can delete them via `Settings / Database passphrase & export`. This is currently in progress. 1. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
2. Senders' SMP relays and recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. 2. Post-quantum resistant key exchange in double ratchet protocol.
3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days). 3. Automatic message queue rotation and redundancy. Currently the queues created between two users are used until the queue is manually changed by the user or contact is deleted. We are planning to add automatic queue rotation to make these identifiers temporary and rotate based on some schedule TBC (e.g., every X messages, or every X hours/days).
4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. 4. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
5. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code. 5. Reproducible builds this is the limitation of the development stack, but we will be investing into solving this problem. Users can still build all applications and services from the source code.
@@ -365,22 +364,26 @@ Please also join [#simplex-devs](https://simplex.chat/contact#/?v=1-2&smp=smp%3A
- ✅ Message editing history - ✅ Message editing history
- ✅ Reduced battery and traffic usage in large groups. - ✅ Reduced battery and traffic usage in large groups.
- ✅ Message delivery confirmation (with sender opt-out per contact). - ✅ Message delivery confirmation (with sender opt-out per contact).
- 🏗 Desktop client. - Desktop client.
- ✅ Encryption of local files stored in the app.
- 🏗 Using mobile profiles from the desktop app.
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- Post-quantum resistant key exchange in double ratchet protocol.
- Large groups, communities and public channels.
- Privacy & security slider - a simple way to set all settings at once.
- Improve sending videos (including encryption of locally stored videos).
- Improve experience for the new users.
- SMP queue redundancy and rotation (manual is supported). - SMP queue redundancy and rotation (manual is supported).
- Include optional message into connection request sent via contact address. - Include optional message into connection request sent via contact address.
- Local app files encryption.
- Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.). - Improved navigation and search in the conversation (expand and scroll to quoted message, scroll to search results, etc.).
- Large groups, communities and public channels.
- Feeds/broadcasts. - Feeds/broadcasts.
- Ephemeral/disappearing/OTR conversations with the existing contacts. - Ephemeral/disappearing/OTR conversations with the existing contacts.
- Privately share your location. - Privately share your location.
- Web widgets for custom interactivity in the chats. - Web widgets for custom interactivity in the chats.
- Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.). - Programmable chat automations / rules (automatic replies/forward/deletion/sending, reminders, etc.).
- Supporting the same profile on multiple devices.
- Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages: - Privacy-preserving identity server for optional DNS-based contact/group addresses to simplify connection and discovery, but not used to deliver messages:
- keep all your contacts and groups even if you lose the domain. - keep all your contacts and groups even if you lose the domain.
- the server doesn't have information about your contacts and groups. - the server doesn't have information about your contacts and groups.
- Message delivery relay for senders (to conceal IP address from the recipients' servers and to reduce the traffic).
- High capacity multi-node SMP relays. - High capacity multi-node SMP relays.
## Disclaimers ## Disclaimers
-1
View File
@@ -55,7 +55,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
didReceiveRemoteNotification userInfo: [AnyHashable : Any], didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
logger.debug("AppDelegate: didReceiveRemoteNotification") logger.debug("AppDelegate: didReceiveRemoteNotification")
print("*** userInfo", userInfo)
let m = ChatModel.shared let m = ChatModel.shared
if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any], if let ntfData = userInfo["notificationData"] as? [AnyHashable : Any],
m.notificationMode != .off { m.notificationMode != .off {
+12 -20
View File
@@ -31,11 +31,11 @@ struct ContentView: View {
@State private var chatListActionSheet: ChatListActionSheet? = nil @State private var chatListActionSheet: ChatListActionSheet? = nil
private enum ChatListActionSheet: Identifiable { private enum ChatListActionSheet: Identifiable {
case connectViaUrl(action: ConnReqType, link: String) case planAndConnectSheet(sheet: PlanAndConnectActionSheet)
var id: String { var id: String {
switch self { switch self {
case let .connectViaUrl(_, link): return "connectViaUrl \(link)" case let .planAndConnectSheet(sheet): return sheet.id
} }
} }
} }
@@ -93,7 +93,7 @@ struct ContentView: View {
mainView() mainView()
.actionSheet(item: $chatListActionSheet) { sheet in .actionSheet(item: $chatListActionSheet) { sheet in
switch sheet { switch sheet {
case let .connectViaUrl(action, link): return connectViaUrlSheet(action, link) case let .planAndConnectSheet(sheet): return planAndConnectActionSheet(sheet, dismiss: false)
} }
} }
} else { } else {
@@ -290,12 +290,16 @@ struct ContentView: View {
if let url = m.appOpenUrl { if let url = m.appOpenUrl {
m.appOpenUrl = nil m.appOpenUrl = nil
var path = url.path var path = url.path
logger.debug("ContentView.connectViaUrl path: \(path)")
if (path == "/contact" || path == "/invitation") { if (path == "/contact" || path == "/invitation") {
path.removeFirst() path.removeFirst()
let action: ConnReqType = path == "contact" ? .contact : .invitation
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
chatListActionSheet = .connectViaUrl(action: action, link: link) planAndConnect(
link,
showAlert: showPlanAndConnectAlert,
showActionSheet: { chatListActionSheet = .planAndConnectSheet(sheet: $0) },
dismiss: false,
incognito: nil
)
} else { } else {
AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid"))) AlertManager.shared.showAlert(Alert(title: Text("Error: URL is invalid")))
} }
@@ -303,20 +307,8 @@ struct ContentView: View {
} }
} }
private func connectViaUrlSheet(_ action: ConnReqType, _ link: String) -> ActionSheet { private func showPlanAndConnectAlert(_ alert: PlanAndConnectAlert) {
let title: LocalizedStringKey AlertManager.shared.showAlert(planAndConnectAlert(alert, dismiss: false))
switch action {
case .contact: title = "Connect via contact link"
case .invitation: title = "Connect via one-time link"
}
return ActionSheet(
title: Text(title),
buttons: [
.default(Text("Use current profile")) { connectViaLink(link, incognito: false) },
.default(Text("Use new incognito profile")) { connectViaLink(link, incognito: true) },
.cancel()
]
)
} }
} }
+93 -63
View File
@@ -62,8 +62,9 @@ final class ChatModel: ObservableObject {
// current chat // current chat
@Published var chatId: String? @Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = [] @Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String? @Published var chatToTop: String?
@Published var groupMembers: [GroupMember] = [] @Published var groupMembers: [GMember] = []
// items in the terminal view // items in the terminal view
@Published var showingTerminal = false @Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = [] @Published var terminalItems: [TerminalItem] = []
@@ -152,6 +153,20 @@ final class ChatModel: ObservableObject {
} }
} }
func getGroupChat(_ groupId: Int64) -> Chat? {
chats.first { chat in
if case let .group(groupInfo) = chat.chatInfo {
return groupInfo.groupId == groupId
} else {
return false
}
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
groupMembers.first { $0.groupMemberId == groupMemberId }
}
private func getChatIndex(_ id: String) -> Int? { private func getChatIndex(_ id: String) -> Int? {
chats.firstIndex(where: { $0.id == id }) chats.firstIndex(where: { $0.id == id })
} }
@@ -165,6 +180,7 @@ final class ChatModel: ObservableObject {
func updateChatInfo(_ cInfo: ChatInfo) { func updateChatInfo(_ cInfo: ChatInfo) {
if let i = getChatIndex(cInfo.id) { if let i = getChatIndex(cInfo.id) {
chats[i].chatInfo = cInfo chats[i].chatInfo = cInfo
chats[i].created = Date.now
} }
} }
@@ -296,7 +312,11 @@ final class ChatModel: ObservableObject {
return false return false
} else { } else {
withAnimation(itemAnimation()) { withAnimation(itemAnimation()) {
reversedChatItems.insert(cItem, at: hasLiveDummy ? 1 : 0) var ci = cItem
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
} }
return true return true
} }
@@ -309,26 +329,22 @@ final class ChatModel: ObservableObject {
} }
} }
func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { func updateChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem, status: CIStatus? = nil) {
if chatId == cInfo.id, let i = getChatItemIndex(cItem) { if chatId == cInfo.id, let i = getChatItemIndex(cItem) {
withAnimation { withAnimation {
_updateChatItem(at: i, with: cItem) _updateChatItem(at: i, with: cItem)
} }
} else if let status = status {
chatItemStatuses.updateValue(status, forKey: cItem.id)
} }
} }
private func _updateChatItem(at i: Int, with cItem: ChatItem) { private func _updateChatItem(at i: Int, with cItem: ChatItem) {
let ci = reversedChatItems[i]
reversedChatItems[i] = cItem reversedChatItems[i] = cItem
reversedChatItems[i].viewTimestamp = .now reversedChatItems[i].viewTimestamp = .now
// on some occasions the confirmation of message being accepted by the server (tick)
// arrives earlier than the response from API, and item remains without tick
if case .sndNew = cItem.meta.itemStatus {
reversedChatItems[i].meta.itemStatus = ci.meta.itemStatus
}
} }
private func getChatItemIndex(_ cItem: ChatItem) -> Int? { func getChatItemIndex(_ cItem: ChatItem) -> Int? {
reversedChatItems.firstIndex(where: { $0.id == cItem.id }) reversedChatItems.firstIndex(where: { $0.id == cItem.id })
} }
@@ -464,6 +480,7 @@ final class ChatModel: ObservableObject {
} }
// clear current chat // clear current chat
if chatId == cInfo.id { if chatId == cInfo.id {
chatItemStatuses = [:]
reversedChatItems = [] reversedChatItems = []
} }
} }
@@ -516,27 +533,62 @@ final class ChatModel: ObservableObject {
users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount }) users.filter { !$0.user.activeUser }.reduce(0, { unread, next -> Int in unread + next.unreadCount })
} }
func getConnectedMemberNames(_ ci: ChatItem) -> [String] { // this function analyses "connected" events and assumes that each member will be there only once
guard var i = getChatItemIndex(ci) else { return [] } func getConnectedMemberNames(_ chatItem: ChatItem) -> (Int, [String]) {
var count = 0
var ns: [String] = [] var ns: [String] = []
while i < reversedChatItems.count, let m = reversedChatItems[i].memberConnected { if let ciCategory = chatItem.mergeCategory,
ns.append(m.displayName) var i = getChatItemIndex(chatItem) {
i += 1 while i < reversedChatItems.count {
let ci = reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected {
ns.append(m.displayName)
}
count += 1
i += 1
}
} }
return ns return (count, ns)
} }
func getChatItemNeighbors(_ ci: ChatItem) -> (ChatItem?, ChatItem?) { // returns the index of the passed item and the next item (it has smaller index)
if let i = getChatItemIndex(ci) { func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
return ( if let i = getChatItemIndex(ci) {
i + 1 < reversedChatItems.count ? reversedChatItems[i + 1] : nil, (i, i > 0 ? reversedChatItems[i - 1] : nil)
i - 1 >= 0 ? reversedChatItems[i - 1] : nil
)
} else { } else {
return (nil, nil) (nil, nil)
} }
} }
// returns the index of the first item in the same merged group (the first hidden item)
// and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) }
let fst = reversedChatItems.count - 1
while i < fst {
i = i + 1
let ci = reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci)
}
}
return (i, nil)
}
// returns the previous member in the same merge group and the count of members in this group
func getPrevHiddenMember(_ member: GroupMember, _ range: ClosedRange<Int>) -> (GroupMember?, Int) {
var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = []
for i in range {
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId)
}
}
return (prevMember, memberIds.count)
}
func popChat(_ id: String) { func popChat(_ id: String) {
if let i = getChatIndex(id) { if let i = getChatIndex(id) {
popChat_(i) popChat_(i)
@@ -571,13 +623,14 @@ final class ChatModel: ObservableObject {
} }
// update current chat // update current chat
if chatId == groupInfo.id { if chatId == groupInfo.id {
if let i = groupMembers.firstIndex(where: { $0.id == member.id }) { if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
withAnimation(.default) { withAnimation(.default) {
self.groupMembers[i] = member self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now
} }
return false return false
} else { } else {
withAnimation { groupMembers.append(member) } withAnimation { groupMembers.append(GMember(member)) }
return true return true
} }
} else { } else {
@@ -586,11 +639,10 @@ final class ChatModel: ObservableObject {
} }
func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) { func updateGroupMemberConnectionStats(_ groupInfo: GroupInfo, _ member: GroupMember, _ connectionStats: ConnectionStats) {
if let conn = member.activeConn { if var conn = member.activeConn {
var updatedConn = conn conn.connectionStats = connectionStats
updatedConn.connectionStats = connectionStats
var updatedMember = member var updatedMember = member
updatedMember.activeConn = updatedConn updatedMember.activeConn = conn
_ = upsertGroupMember(groupInfo, updatedMember) _ = upsertGroupMember(groupInfo, updatedMember)
} }
} }
@@ -689,40 +741,18 @@ final class Chat: ObservableObject, Identifiable {
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
} }
enum NetworkStatus: Decodable, Equatable { final class GMember: ObservableObject, Identifiable {
case unknown @Published var wrapped: GroupMember
case connected var created = Date.now
case disconnected
case error(String)
var statusString: LocalizedStringKey { init(_ member: GroupMember) {
get { self.wrapped = member
switch self {
case .connected: return "connected"
case .error: return "error"
default: return "connecting"
}
}
} }
var statusExplanation: LocalizedStringKey { var id: String { wrapped.id }
get { var groupId: Int64 { wrapped.groupId }
switch self { var groupMemberId: Int64 { wrapped.groupMemberId }
case .connected: return "You are connected to the server used to receive messages from this contact." var displayName: String { wrapped.displayName }
case let .error(err): return "Trying to connect to the server used to receive messages from this contact (error: \(err))." var viewId: String { get { "\(wrapped.id) \(created.timeIntervalSince1970)" } }
default: return "Trying to connect to the server used to receive messages from this contact." static let sampleData = GMember(GroupMember.sampleData)
}
}
}
var imageName: String {
get {
switch self {
case .unknown: return "circle.dotted"
case .connected: return "circle.fill"
case .disconnected: return "ellipsis.circle.fill"
case .error: return "exclamationmark.circle.fill"
}
}
}
} }
+83 -27
View File
@@ -257,6 +257,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
throw r throw r
} }
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
let r = chatSendCmdSync(.apiSetEncryptLocalFiles(enable: enable))
if case .cmdOk = r { return }
throw r
}
func apiExportArchive(config: ArchiveConfig) async throws { func apiExportArchive(config: ArchiveConfig) async throws {
try await sendCommandOkResp(.apiExportArchive(config: config)) try await sendCommandOkResp(.apiExportArchive(config: config))
} }
@@ -306,6 +312,7 @@ func loadChat(chat: Chat, search: String = "") {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
let m = ChatModel.shared let m = ChatModel.shared
m.chatItemStatuses = [:]
m.reversedChatItems = [] m.reversedChatItems = []
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
m.updateChatInfo(chat.chatInfo) m.updateChatInfo(chat.chatInfo)
@@ -495,6 +502,10 @@ func apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) a
try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings)) try await sendCommandOkResp(.apiSetChatSettings(type: type, id: id, chatSettings: chatSettings))
} }
func apiSetMemberSettings(_ groupId: Int64, _ groupMemberId: Int64, _ memberSettings: GroupMemberSettings) async throws {
try await sendCommandOkResp(.apiSetMemberSettings(groupId: groupId, groupMemberId: groupMemberId, memberSettings: memberSettings))
}
func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) { func apiContactInfo(_ contactId: Int64) async throws -> (ConnectionStats?, Profile?) {
let r = await chatSendCmd(.apiContactInfo(contactId: contactId)) let r = await chatSendCmd(.apiContactInfo(contactId: contactId))
if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) } if case let .contactInfo(_, _, connStats, customUserProfile) = r { return (connStats, customUserProfile) }
@@ -586,6 +597,14 @@ func apiSetConnectionIncognito(connId: Int64, incognito: Bool) async throws -> P
throw r throw r
} }
func apiConnectPlan(connReq: String) async throws -> ConnectionPlan {
let userId = try currentUserId("apiConnectPlan")
let r = await chatSendCmd(.apiConnectPlan(userId: userId, connReq: connReq))
if case let .connectionPlan(_, connectionPlan) = r { return connectionPlan }
logger.error("apiConnectPlan error: \(responseError(r))")
throw r
}
func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? { func apiConnect(incognito: Bool, connReq: String) async -> ConnReqType? {
let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq) let (connReqType, alert) = await apiConnect_(incognito: incognito, connReq: connReq)
if let alert = alert { if let alert = alert {
@@ -610,10 +629,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
await MainActor.run { m.chatId = c.id } await MainActor.run { m.chatId = c.id }
} }
let alert = mkAlert( let alert = contactAlreadyExistsAlert(contact)
title: "Contact already exists",
message: "You are already connected to \(contact.displayName)."
)
return (nil, alert) return (nil, alert)
case .chatCmdError(_, .error(.invalidConnReq)): case .chatCmdError(_, .error(.invalidConnReq)):
let alert = mkAlert( let alert = mkAlert(
@@ -641,6 +657,13 @@ func apiConnect_(incognito: Bool, connReq: String) async -> (ConnReqType?, Alert
return (nil, alert) return (nil, alert)
} }
func contactAlreadyExistsAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
message: "You are already connected to \(contact.displayName)."
)
}
private func connectionErrorAlert(_ r: ChatResponse) -> Alert { private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
if let networkErrorAlert = networkErrorAlert(r) { if let networkErrorAlert = networkErrorAlert(r) {
return networkErrorAlert return networkErrorAlert
@@ -652,18 +675,18 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
} }
} }
func apiDeleteChat(type: ChatType, id: Int64) async throws { func apiDeleteChat(type: ChatType, id: Int64, notify: Bool? = nil) async throws {
let r = await chatSendCmd(.apiDeleteChat(type: type, id: id), bgTask: false) let r = await chatSendCmd(.apiDeleteChat(type: type, id: id, notify: notify), bgTask: false)
if case .direct = type, case .contactDeleted = r { return } if case .direct = type, case .contactDeleted = r { return }
if case .contactConnection = type, case .contactConnectionDeleted = r { return } if case .contactConnection = type, case .contactConnectionDeleted = r { return }
if case .group = type, case .groupDeletedUser = r { return } if case .group = type, case .groupDeletedUser = r { return }
throw r throw r
} }
func deleteChat(_ chat: Chat) async { func deleteChat(_ chat: Chat, notify: Bool? = nil) async {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId) try await apiDeleteChat(type: cInfo.chatType, id: cInfo.apiId, notify: notify)
DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) } DispatchQueue.main.async { ChatModel.shared.removeChat(cInfo.id) }
} catch let error { } catch let error {
logger.error("deleteChat apiDeleteChat error: \(responseError(error))") logger.error("deleteChat apiDeleteChat error: \(responseError(error))")
@@ -701,8 +724,9 @@ func apiUpdateProfile(profile: Profile) async throws -> (Profile, [Contact])? {
let userId = try currentUserId("apiUpdateProfile") let userId = try currentUserId("apiUpdateProfile")
let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile)) let r = await chatSendCmd(.apiUpdateProfile(userId: userId, profile: profile))
switch r { switch r {
case .userProfileNoChange: return nil case .userProfileNoChange: return (profile, [])
case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts) case let .userProfileUpdated(_, _, toProfile, updateSummary): return (toProfile, updateSummary.changedContacts)
case .chatCmdError(_, .errorStore(.duplicateName)): return nil;
default: throw r default: throw r
} }
} }
@@ -944,6 +968,12 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws {
} }
} }
func apiGetNetworkStatuses() throws -> [ConnNetworkStatus] {
let r = chatSendCmdSync(.apiGetNetworkStatuses)
if case let .networkStatuses(_, statuses) = r { return statuses }
throw r
}
func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async { func markChatRead(_ chat: Chat, aboveItem: ChatItem? = nil) async {
do { do {
if chat.chatStats.unreadCount > 0 { if chat.chatStats.unreadCount > 0 {
@@ -991,9 +1021,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
throw r throw r
} }
func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo { func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo {
let userId = try currentUserId("apiNewGroup") let userId = try currentUserId("apiNewGroup")
let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p)) let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
if case let .groupCreated(_, groupInfo) = r { return groupInfo } if case let .groupCreated(_, groupInfo) = r { return groupInfo }
throw r throw r
} }
@@ -1053,8 +1083,8 @@ func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
return [] return []
} }
func filterMembersToAdd(_ ms: [GroupMember]) -> [Contact] { func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
let memberContactIds = ms.compactMap{ m in m.memberCurrent ? m.memberContactId : nil } let memberContactIds = ms.compactMap{ m in m.wrapped.memberCurrent ? m.wrapped.memberContactId : nil }
return ChatModel.shared.chats return ChatModel.shared.chats
.compactMap{ $0.chatInfo.contact } .compactMap{ $0.chatInfo.contact }
.filter{ !memberContactIds.contains($0.apiId) } .filter{ !memberContactIds.contains($0.apiId) }
@@ -1133,6 +1163,7 @@ func initializeChat(start: Bool, dbKey: String? = nil, refreshInvitations: Bool
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(getXFTPCfg()) try setXFTPConfig(getXFTPCfg())
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
m.chatInitialized = true m.chatInitialized = true
m.currentUser = try apiGetActiveUser() m.currentUser = try apiGetActiveUser()
if m.currentUser == nil { if m.currentUser == nil {
@@ -1285,6 +1316,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(connection.id) m.removeChat(connection.id)
} }
} }
case let .contactDeletedByContact(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
}
}
case let .contactConnected(user, contact, _): case let .contactConnected(user, contact, _):
if active(user) && contact.directOrUsed { if active(user) && contact.directOrUsed {
await MainActor.run { await MainActor.run {
@@ -1329,6 +1366,12 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatInfo(cInfo) m.updateChatInfo(cInfo)
} }
} }
case let .groupMemberUpdated(user, groupInfo, _, toMember):
if active(user) {
await MainActor.run {
_ = m.upsertGroupMember(groupInfo, toMember)
}
}
case let .contactsMerged(user, intoContact, mergedContact): case let .contactsMerged(user, intoContact, mergedContact):
if active(user) && m.hasChat(mergedContact.id) { if active(user) && m.hasChat(mergedContact.id) {
await MainActor.run { await MainActor.run {
@@ -1342,13 +1385,6 @@ func processReceivedMsg(_ res: ChatResponse) async {
await updateContactsStatus(contactRefs, status: .connected) await updateContactsStatus(contactRefs, status: .connected)
case let .contactsDisconnected(_, contactRefs): case let .contactsDisconnected(_, contactRefs):
await updateContactsStatus(contactRefs, status: .disconnected) await updateContactsStatus(contactRefs, status: .disconnected)
case let .contactSubError(user, contact, chatError):
await MainActor.run {
if active(user) {
m.updateContact(contact)
}
processContactSubError(contact, chatError)
}
case let .contactSubSummary(_, contactSubscriptions): case let .contactSubSummary(_, contactSubscriptions):
await MainActor.run { await MainActor.run {
for sub in contactSubscriptions { for sub in contactSubscriptions {
@@ -1363,6 +1399,18 @@ func processReceivedMsg(_ res: ChatResponse) async {
} }
} }
} }
case let .networkStatus(status, connections):
await MainActor.run {
for cId in connections {
m.networkStatuses[cId] = status
}
}
case let .networkStatuses(_, statuses): ()
await MainActor.run {
for s in statuses {
m.networkStatuses[s.agentConnId] = s.networkStatus
}
}
case let .newChatItem(user, aChatItem): case let .newChatItem(user, aChatItem):
let cInfo = aChatItem.chatInfo let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem let cItem = aChatItem.chatItem
@@ -1384,11 +1432,8 @@ func processReceivedMsg(_ res: ChatResponse) async {
case let .chatItemStatusUpdated(user, aChatItem): case let .chatItemStatusUpdated(user, aChatItem):
let cInfo = aChatItem.chatInfo let cInfo = aChatItem.chatInfo
let cItem = aChatItem.chatItem let cItem = aChatItem.chatItem
if !cItem.isDeletedContent { if !cItem.isDeletedContent && active(user) {
let added = active(user) ? await MainActor.run { m.upsertChatItem(cInfo, cItem) } : true await MainActor.run { m.updateChatItem(cInfo, cItem, status: cItem.meta.itemStatus) }
if added && cItem.showNotification {
NtfManager.shared.notifyMessageReceived(user, cInfo, cItem)
}
} }
if let endTask = m.messageDelivery[cItem.id] { if let endTask = m.messageDelivery[cItem.id] {
switch cItem.meta.itemStatus { switch cItem.meta.itemStatus {
@@ -1440,6 +1485,16 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.removeChat(hostContact.activeConn.id) m.removeChat(hostContact.activeConn.id)
} }
} }
case let .groupLinkConnecting(user, groupInfo, hostMember):
if !active(user) { return }
await MainActor.run {
m.updateGroup(groupInfo)
if let hostConn = hostMember.activeConn {
m.dismissConnReqView(hostConn.id)
m.removeChat(hostConn.id)
}
}
case let .joinedGroupMemberConnecting(user, groupInfo, _, member): case let .joinedGroupMemberConnecting(user, groupInfo, _, member):
if active(user) { if active(user) {
await MainActor.run { await MainActor.run {
@@ -1499,10 +1554,11 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateGroup(toGroup) m.updateGroup(toGroup)
} }
} }
case let .memberRole(user, groupInfo, _, _, _, _): case let .memberRole(user, groupInfo, byMember: _, member: member, fromRole: _, toRole: _):
if active(user) { if active(user) {
await MainActor.run { await MainActor.run {
m.updateGroup(groupInfo) m.updateGroup(groupInfo)
_ = m.upsertGroupMember(groupInfo, member)
} }
} }
case let .newMemberContactReceivedInv(user, contact, _, _): case let .newMemberContactReceivedInv(user, contact, _, _):
@@ -1643,7 +1699,7 @@ func processContactSubError(_ contact: Contact, _ chatError: ChatError) {
case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted" case .errorAgent(agentError: .SMP(smpErr: .AUTH)): err = "contact deleted"
default: err = String(describing: chatError) default: err = String(describing: chatError)
} }
m.setContactNetworkStatus(contact, .error(err)) m.setContactNetworkStatus(contact, .error(connectionError: err))
} }
func refreshCallInvitations() throws { func refreshCallInvitations() throws {
+23 -5
View File
@@ -19,7 +19,11 @@ let bgSuspendTimeout: Int = 5 // seconds
let terminationTimeout: Int = 3 // seconds let terminationTimeout: Int = 3 // seconds
private func _suspendChat(timeout: Int) { private func _suspendChat(timeout: Int) {
if ChatModel.ok { // this is a redundant check to prevent logical errors, like the one fixed in this PR
let state = appStateGroupDefault.get()
if !state.canSuspend {
logger.error("_suspendChat called, current state: \(state.rawValue, privacy: .public)")
} else if ChatModel.ok {
appStateGroupDefault.set(.suspending) appStateGroupDefault.set(.suspending)
apiSuspendChat(timeoutMicroseconds: timeout * 1000000) apiSuspendChat(timeoutMicroseconds: timeout * 1000000)
let endTask = beginBGTask(chatSuspended) let endTask = beginBGTask(chatSuspended)
@@ -31,9 +35,7 @@ private func _suspendChat(timeout: Int) {
func suspendChat() { func suspendChat() {
suspendLockQueue.sync { suspendLockQueue.sync {
if appStateGroupDefault.get() != .stopped { _suspendChat(timeout: appSuspendTimeout)
_suspendChat(timeout: appSuspendTimeout)
}
} }
} }
@@ -45,15 +47,25 @@ func suspendBgRefresh() {
} }
} }
private var terminating = false
func terminateChat() { func terminateChat() {
logger.debug("terminateChat")
suspendLockQueue.sync { suspendLockQueue.sync {
switch appStateGroupDefault.get() { switch appStateGroupDefault.get() {
case .suspending: case .suspending:
// suspend instantly if already suspending // suspend instantly if already suspending
_chatSuspended() _chatSuspended()
// when apiSuspendChat is called with timeout 0, it won't send any events on suspension
if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) } if ChatModel.ok { apiSuspendChat(timeoutMicroseconds: 0) }
case .stopped: () chatCloseStore()
case .suspended:
chatCloseStore()
case .stopped:
chatCloseStore()
default: default:
terminating = true
// the store will be closed in _chatSuspended when event is received
_suspendChat(timeout: terminationTimeout) _suspendChat(timeout: terminationTimeout)
} }
} }
@@ -73,10 +85,14 @@ private func _chatSuspended() {
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.stop() ChatReceiver.shared.stop()
} }
if terminating {
chatCloseStore()
}
} }
func activateChat(appState: AppState = .active) { func activateChat(appState: AppState = .active) {
logger.debug("DEBUGGING: activateChat") logger.debug("DEBUGGING: activateChat")
terminating = false
suspendLockQueue.sync { suspendLockQueue.sync {
appStateGroupDefault.set(appState) appStateGroupDefault.set(appState)
if ChatModel.ok { apiActivateChat() } if ChatModel.ok { apiActivateChat() }
@@ -85,6 +101,7 @@ func activateChat(appState: AppState = .active) {
} }
func initChatAndMigrate(refreshInvitations: Bool = true) { func initChatAndMigrate(refreshInvitations: Bool = true) {
terminating = false
let m = ChatModel.shared let m = ChatModel.shared
if (!m.chatInitialized) { if (!m.chatInitialized) {
do { do {
@@ -97,6 +114,7 @@ func initChatAndMigrate(refreshInvitations: Bool = true) {
} }
func startChatAndActivate() { func startChatAndActivate() {
terminating = false
logger.debug("DEBUGGING: startChatAndActivate") logger.debug("DEBUGGING: startChatAndActivate")
if ChatModel.shared.chatRunning == true { if ChatModel.shared.chatRunning == true {
ChatReceiver.shared.start() ChatReceiver.shared.start()
@@ -108,7 +108,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try audioSession.setActive(true) try audioSession.setActive(true)
logger.debug("audioSession activated") logger.debug("audioSession activated")
} catch { } catch {
print(error)
logger.error("failed activating audio session") logger.error("failed activating audio session")
} }
} }
@@ -121,7 +120,6 @@ class CallController: NSObject, CXProviderDelegate, PKPushRegistryDelegate, Obse
try audioSession.setActive(false) try audioSession.setActive(false)
logger.debug("audioSession deactivated") logger.debug("audioSession deactivated")
} catch { } catch {
print(error)
logger.error("failed deactivating audio session") logger.error("failed deactivating audio session")
} }
suspendOnEndCall() suspendOnEndCall()
+43 -33
View File
@@ -99,12 +99,12 @@ struct ChatInfoView: View {
@Binding var connectionCode: String? @Binding var connectionCode: String?
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var alert: ChatInfoViewAlert? = nil @State private var alert: ChatInfoViewAlert? = nil
@State private var showDeleteContactActionSheet = false
@State private var sendReceipts = SendReceipts.userDefault(true) @State private var sendReceipts = SendReceipts.userDefault(true)
@State private var sendReceiptsUserDefault = true @State private var sendReceiptsUserDefault = true
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
enum ChatInfoViewAlert: Identifiable { enum ChatInfoViewAlert: Identifiable {
case deleteContactAlert
case clearChatAlert case clearChatAlert
case networkStatusAlert case networkStatusAlert
case switchAddressAlert case switchAddressAlert
@@ -114,7 +114,6 @@ struct ChatInfoView: View {
var id: String { var id: String {
switch self { switch self {
case .deleteContactAlert: return "deleteContactAlert"
case .clearChatAlert: return "clearChatAlert" case .clearChatAlert: return "clearChatAlert"
case .networkStatusAlert: return "networkStatusAlert" case .networkStatusAlert: return "networkStatusAlert"
case .switchAddressAlert: return "switchAddressAlert" case .switchAddressAlert: return "switchAddressAlert"
@@ -164,13 +163,13 @@ struct ChatInfoView: View {
// synchronizeConnectionButtonForce() // synchronizeConnectionButtonForce()
// } // }
} }
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
if let contactLink = contact.contactLink { if let contactLink = contact.contactLink {
Section { Section {
QRCode(uri: contactLink) SimpleXLinkQRCode(uri: contactLink)
Button { Button {
showShareSheet(items: [contactLink]) showShareSheet(items: [simplexChatLink(contactLink)])
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
@@ -181,7 +180,7 @@ struct ChatInfoView: View {
} }
} }
if contact.ready { if contact.ready && contact.active {
Section("Servers") { Section("Servers") {
networkStatusRow() networkStatusRow()
.onTapGesture { .onTapGesture {
@@ -192,8 +191,7 @@ struct ChatInfoView: View {
alert = .switchAddressAlert alert = .switchAddressAlert
} }
.disabled( .disabled(
!contact.ready connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.rcvQueuesInfo.contains { $0.rcvSwitchStatus != nil }
|| connStats.ratchetSyncSendProhibited || connStats.ratchetSyncSendProhibited
) )
if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) { if connStats.rcvQueuesInfo.contains(where: { $0.rcvSwitchStatus != nil }) {
@@ -234,7 +232,6 @@ struct ChatInfoView: View {
} }
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
switch(alertItem) { switch(alertItem) {
case .deleteContactAlert: return deleteContactAlert()
case .clearChatAlert: return clearChatAlert() case .clearChatAlert: return clearChatAlert()
case .networkStatusAlert: return networkStatusAlert() case .networkStatusAlert: return networkStatusAlert()
case .switchAddressAlert: return switchAddressAlert(switchContactAddress) case .switchAddressAlert: return switchAddressAlert(switchContactAddress)
@@ -243,6 +240,26 @@ struct ChatInfoView: View {
case let .error(title, error): return mkAlert(title: title, message: error) case let .error(title, error): return mkAlert(title: title, message: error)
} }
} }
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { deleteContact(notify: true) },
.destructive(Text("Delete")) { deleteContact(notify: false) },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { deleteContact() },
.cancel()
]
)
}
}
} }
private func contactInfoHeader() -> some View { private func contactInfoHeader() -> some View {
@@ -415,7 +432,7 @@ struct ChatInfoView: View {
private func deleteContactButton() -> some View { private func deleteContactButton() -> some View {
Button(role: .destructive) { Button(role: .destructive) {
alert = .deleteContactAlert showDeleteContactActionSheet = true
} label: { } label: {
Label("Delete contact", systemImage: "trash") Label("Delete contact", systemImage: "trash")
.foregroundColor(Color.red) .foregroundColor(Color.red)
@@ -431,30 +448,23 @@ struct ChatInfoView: View {
} }
} }
private func deleteContactAlert() -> Alert { private func deleteContact(notify: Bool? = nil) {
Alert( Task {
title: Text("Delete contact?"), do {
message: Text("Contact and all messages will be deleted - this cannot be undone!"), try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, notify: notify)
primaryButton: .destructive(Text("Delete")) { await MainActor.run {
Task { dismiss()
do { chatModel.chatId = nil
try await apiDeleteChat(type: chat.chatInfo.chatType, id: chat.chatInfo.apiId) chatModel.removeChat(chat.chatInfo.id)
await MainActor.run {
dismiss()
chatModel.chatId = nil
chatModel.removeChat(chat.chatInfo.id)
}
} catch let error {
logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
} }
}, } catch let error {
secondaryButton: .cancel() logger.error("deleteContactAlert apiDeleteChat error: \(responseError(error))")
) let a = getErrorAlert(error, "Error deleting contact")
await MainActor.run {
alert = .error(title: a.title, error: a.message)
}
}
}
} }
private func clearChatAlert() -> Alert { private func clearChatAlert() -> Alert {
@@ -11,7 +11,7 @@ import SimpleXChat
struct CICallItemView: View { struct CICallItemView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
var chatInfo: ChatInfo @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var status: CICallStatus var status: CICallStatus
var duration: Int var duration: Int
@@ -60,7 +60,7 @@ struct CICallItemView: View {
@ViewBuilder private func acceptCallButton() -> some View { @ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chatInfo { if case let .direct(contact) = chat.chatInfo {
Button { Button {
if let invitation = m.callInvitations[contact.id] { if let invitation = m.callInvitations[contact.id] {
CallController.shared.answerCall(invitation: invitation) CallController.shared.answerCall(invitation: invitation)
@@ -10,20 +10,92 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIChatFeatureView: View { struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var feature: Feature var feature: Feature
var icon: String? = nil var icon: String? = nil
var iconColor: Color var iconColor: Color
var body: some View { var body: some View {
if !revealed, let fs = mergedFeautures() {
HStack {
ForEach(fs, content: featureIconView)
}
.padding(.horizontal, 6)
.padding(.vertical, 6)
} else {
fullFeatureView
}
}
private struct FeatureInfo: Identifiable {
var icon: String
var scale: CGFloat
var color: Color
var param: String?
init(_ f: Feature, _ color: Color, _ param: Int?) {
self.icon = f.iconFilled
self.scale = f.iconScale
self.color = color
self.param = f.hasParam && param != nil ? timeText(param) : nil
}
var id: String {
"\(icon) \(color) \(param ?? "")"
}
}
private func mergedFeautures() -> [FeatureInfo]? {
var fs: [FeatureInfo] = []
var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) {
while i < m.reversedChatItems.count,
let f = featureInfo(m.reversedChatItems[i]) {
if !icons.contains(f.icon) {
fs.insert(f, at: 0)
icons.insert(f.icon)
}
i += 1
}
}
return fs.count > 1 ? fs : nil
}
private func featureInfo(_ ci: ChatItem) -> FeatureInfo? {
switch ci.content {
case let .rcvChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .sndChatFeature(feature, enabled, param): FeatureInfo(feature, enabled.iconColor, param)
case let .rcvGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
case let .sndGroupFeature(feature, preference, param): FeatureInfo(feature, preference.enable.iconColor, param)
default: nil
}
}
@ViewBuilder private func featureIconView(_ f: FeatureInfo) -> some View {
let i = Image(systemName: f.icon)
.foregroundColor(f.color)
.scaleEffect(f.scale)
if let param = f.param {
HStack {
i
chatEventText(Text(param)).lineLimit(1)
}
} else {
i
}
}
private var fullFeatureView: some View {
HStack(alignment: .bottom, spacing: 4) { HStack(alignment: .bottom, spacing: 4) {
Image(systemName: icon ?? feature.iconFilled) Image(systemName: icon ?? feature.iconFilled)
.foregroundColor(iconColor) .foregroundColor(iconColor)
.scaleEffect(feature.iconScale) .scaleEffect(feature.iconScale)
chatEventText(chatItem) chatEventText(chatItem)
} }
.padding(.leading, 6) .padding(.horizontal, 6)
.padding(.bottom, 6) .padding(.vertical, 4)
.textSelection(.disabled) .textSelection(.disabled)
} }
} }
@@ -31,6 +103,6 @@ struct CIChatFeatureView: View {
struct CIChatFeatureView_Previews: PreviewProvider { struct CIChatFeatureView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let enabled = FeatureEnabled(forUser: false, forContact: false) let enabled = FeatureEnabled(forUser: false, forContact: false)
CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor) CIChatFeatureView(chatItem: ChatItem.getChatFeatureSample(.fullDelete, enabled), revealed: Binding.constant(true), feature: ChatFeature.fullDelete, iconColor: enabled.iconColor)
} }
} }
@@ -13,11 +13,9 @@ struct CIEventView: View {
var eventText: Text var eventText: Text
var body: some View { var body: some View {
HStack(alignment: .bottom, spacing: 0) { eventText
eventText .padding(.horizontal, 6)
} .padding(.vertical, 4)
.padding(.leading, 6)
.padding(.bottom, 6)
.textSelection(.disabled) .textSelection(.disabled)
} }
} }
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIFeaturePreferenceView: View { struct CIFeaturePreferenceView: View {
@EnvironmentObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var feature: ChatFeature var feature: ChatFeature
var allowed: FeatureAllowed var allowed: FeatureAllowed
@@ -80,7 +80,6 @@ struct CIFeaturePreferenceView_Previews: PreviewProvider {
quotedItem: nil, quotedItem: nil,
file: nil file: nil
) )
CIFeaturePreferenceView(chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30) CIFeaturePreferenceView(chat: Chat.sampleData, chatItem: chatItem, feature: ChatFeature.timedMessages, allowed: .yes, param: 30)
.environmentObject(Chat.sampleData)
} }
} }
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIFileView: View { struct CIFileView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let file: CIFile? let file: CIFile?
let edited: Bool let edited: Bool
@@ -83,7 +84,7 @@ struct CIFileView: View {
if fileSizeValid() { if fileSizeValid() {
Task { Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, in Task") logger.debug("CIFileView fileAction - in .rcvInvitation, in Task")
if let user = ChatModel.shared.currentUser { if let user = m.currentUser {
let encrypted = privacyEncryptLocalFilesGroupDefault.get() let encrypted = privacyEncryptLocalFilesGroupDefault.get()
await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted) await receiveFile(user: user, fileId: file.fileId, encrypted: encrypted)
} }
@@ -234,18 +235,17 @@ struct CIFileView_Previews: PreviewProvider {
file: nil file: nil
) )
Group { Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentFile, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: sentFile, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileName: "some_long_file_name_here", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvAccepted), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileStatus: .rcvCancelled), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(fileSize: 1_000_000_000, fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "Hello there", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getFileMsgContentSample(text: "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.", fileStatus: .rcvInvitation), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getFileMsgContentSample(text: "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.", fileStatus: .rcvInvitation), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: fileChatItemWtFile, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: fileChatItemWtFile, revealed: Binding.constant(false))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
} }
} }
@@ -17,34 +17,45 @@ struct CIGroupInvitationView: View {
var memberRole: GroupMemberRole var memberRole: GroupMemberRole
var chatIncognito: Bool = false var chatIncognito: Bool = false
@State private var frameWidth: CGFloat = 0 @State private var frameWidth: CGFloat = 0
@State private var inProgress = false
@State private var progressByTimeout = false
var body: some View { var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) { let v = ZStack(alignment: .bottomTrailing) {
VStack(alignment: .leading) { ZStack {
groupInfoView(action) VStack(alignment: .leading) {
.padding(.horizontal, 2) groupInfoView(action)
.padding(.top, 8) .padding(.horizontal, 2)
.padding(.bottom, 6) .padding(.top, 8)
.overlay(DetermineWidth()) .padding(.bottom, 6)
.overlay(DetermineWidth())
Divider().frame(width: frameWidth) Divider().frame(width: frameWidth)
if action { if action {
groupInvitationText() VStack(alignment: .leading, spacing: 2) {
.overlay(DetermineWidth()) groupInvitationText()
Text(chatIncognito ? "Tap to join incognito" : "Tap to join") .overlay(DetermineWidth())
.foregroundColor(chatIncognito ? .indigo : .accentColor) Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.font(.callout) .foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.padding(.trailing, 60) .font(.callout)
.overlay(DetermineWidth()) .padding(.trailing, 60)
} else { .overlay(DetermineWidth())
groupInvitationText() }
.padding(.trailing, 60) } else {
.overlay(DetermineWidth()) groupInvitationText()
.padding(.trailing, 60)
.overlay(DetermineWidth())
}
}
.padding(.bottom, 2)
if progressByTimeout {
ProgressView().scaleEffect(2)
} }
} }
.padding(.bottom, 2)
chatItem.timestampText chatItem.timestampText
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -55,11 +66,24 @@ struct CIGroupInvitationView: View {
.cornerRadius(18) .cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
}
if action { if action {
v.onTapGesture { v.onTapGesture {
joinGroup(groupInvitation.groupId) inProgress = true
joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false }
}
} }
.disabled(inProgress)
} else { } else {
v v
} }
@@ -67,7 +91,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View { private func groupInfoView(_ action: Bool) -> some View {
var color: Color var color: Color
if action { if action && !inProgress {
color = chatIncognito ? .indigo : .accentColor color = chatIncognito ? .indigo : .accentColor
} else { } else {
color = Color(uiColor: .tertiaryLabel) color = Color(uiColor: .tertiaryLabel)
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIImageView: View { struct CIImageView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
let chatItem: ChatItem let chatItem: ChatItem
let image: String let image: String
@@ -36,7 +37,7 @@ struct CIImageView: View {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation: case .rcvInvitation:
Task { Task {
if let user = ChatModel.shared.currentUser { if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile) await receiveFile(user: user, fileId: file.fileId, encrypted: chatItem.encryptLocalFile)
} }
} }
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIMemberCreatedContactView: View { struct CIMemberCreatedContactView: View {
@EnvironmentObject var m: ChatModel
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -21,7 +22,7 @@ struct CIMemberCreatedContactView: View {
.onTapGesture { .onTapGesture {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
DispatchQueue.main.async { DispatchQueue.main.async {
ChatModel.shared.chatId = "@\(contactId)" m.chatId = "@\(contactId)"
} }
} }
} else { } else {
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIMetaView: View { struct CIMetaView: View {
@EnvironmentObject var chat: Chat @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var metaColor = Color.secondary var metaColor = Color.secondary
var paleMetaColor = Color(UIColor.tertiaryLabel) var paleMetaColor = Color(UIColor.tertiaryLabel)
@@ -95,15 +95,14 @@ private func statusIconText(_ icon: String, _ color: Color) -> Text {
struct CIMetaView_Previews: PreviewProvider { struct CIMetaView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete))) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial))) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .partial)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete))) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .complete)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial))) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .ok, sndProgress: .partial)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete))) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndRcvd(msgRcptStatus: .badMsgHash, sndProgress: .complete)))
CIMetaView(chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true)) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent(sndProgress: .complete), itemEdited: true))
CIMetaView(chatItem: ChatItem.getDeletedContentSample()) CIMetaView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
} }
.previewLayout(.fixed(width: 360, height: 100)) .previewLayout(.fixed(width: 360, height: 100))
.environmentObject(Chat.sampleData)
} }
} }
@@ -12,7 +12,8 @@ import SimpleXChat
let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup." let decryptErrorReason: LocalizedStringKey = "It can happen when you or your connection used the old database backup."
struct CIRcvDecryptionError: View { struct CIRcvDecryptionError: View {
@EnvironmentObject var chat: Chat @EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
var msgDecryptError: MsgDecryptError var msgDecryptError: MsgDecryptError
var msgCount: UInt32 var msgCount: UInt32
var chatItem: ChatItem var chatItem: ChatItem
@@ -45,7 +46,7 @@ struct CIRcvDecryptionError: View {
do { do {
let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId) let (member, stats) = try apiGroupMemberInfo(groupInfo.apiId, groupMember.groupMemberId)
if let s = stats { if let s = stats {
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, member, s) m.updateGroupMemberConnectionStats(groupInfo, member, s)
} }
} catch let error { } catch let error {
logger.error("apiGroupMemberInfo error: \(responseError(error))") logger.error("apiGroupMemberInfo error: \(responseError(error))")
@@ -79,8 +80,8 @@ struct CIRcvDecryptionError: View {
} }
} else if case let .group(groupInfo) = chat.chatInfo, } else if case let .group(groupInfo) = chat.chatInfo,
case let .groupRcv(groupMember) = chatItem.chatDir, case let .groupRcv(groupMember) = chatItem.chatDir,
let modelMember = ChatModel.shared.groupMembers.first(where: { $0.id == groupMember.id }), let mem = m.getGroupMember(groupMember.groupMemberId),
let memberStats = modelMember.activeConn?.connectionStats { let memberStats = mem.wrapped.activeConn?.connectionStats {
if memberStats.ratchetSyncAllowed { if memberStats.ratchetSyncAllowed {
decryptionErrorItemFixButton(syncSupported: true) { decryptionErrorItemFixButton(syncSupported: true) {
alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) } alert = .syncAllowedAlert { syncMemberConnection(groupInfo, groupMember) }
@@ -122,7 +123,7 @@ struct CIRcvDecryptionError: View {
) )
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chatItem: chatItem) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
@@ -142,7 +143,7 @@ struct CIRcvDecryptionError: View {
+ ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true) + ciMetaText(chatItem.meta, chatTTL: nil, encrypted: nil, transparent: true)
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
CIMetaView(chatItem: chatItem) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .onTapGesture(perform: { onClick() })
@@ -173,7 +174,7 @@ struct CIRcvDecryptionError: View {
do { do {
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false) let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, false)
await MainActor.run { await MainActor.run {
ChatModel.shared.updateGroupMemberConnectionStats(groupInfo, mem, stats) m.updateGroupMemberConnectionStats(groupInfo, mem, stats)
} }
} catch let error { } catch let error {
logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))") logger.error("syncMemberConnection apiSyncGroupMemberRatchet error: \(responseError(error))")
@@ -190,7 +191,7 @@ struct CIRcvDecryptionError: View {
do { do {
let stats = try apiSyncContactRatchet(contact.apiId, false) let stats = try apiSyncContactRatchet(contact.apiId, false)
await MainActor.run { await MainActor.run {
ChatModel.shared.updateContactConnectionStats(contact, stats) m.updateContactConnectionStats(contact, stats)
} }
} catch let error { } catch let error {
logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))") logger.error("syncContactConnection apiSyncContactRatchet error: \(responseError(error))")
@@ -11,6 +11,7 @@ import AVKit
import SimpleXChat import SimpleXChat
struct CIVideoView: View { struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
private let chatItem: ChatItem private let chatItem: ChatItem
private let image: String private let image: String
@@ -101,7 +102,7 @@ struct CIVideoView: View {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete
VideoPlayerView(player: player, url: url, showControls: false) VideoPlayerView(player: player, url: url, showControls: false)
.frame(width: w, height: w * preview.size.height / preview.size.width) .frame(width: w, height: w * preview.size.height / preview.size.width)
.onChange(of: ChatModel.shared.stopPreviousRecPlay) { playingUrl in .onChange(of: m.stopPreviousRecPlay) { playingUrl in
if playingUrl != url { if playingUrl != url {
player.pause() player.pause()
videoPlaying = false videoPlaying = false
@@ -124,7 +125,7 @@ struct CIVideoView: View {
} }
if !videoPlaying { if !videoPlaying {
Button { Button {
ChatModel.shared.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
player.play() player.play()
} label: { } label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash") playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
@@ -256,7 +257,7 @@ struct CIVideoView: View {
// TODO encrypt: where file size is checked? // TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) { private func receiveFileIfValidSize(file: CIFile, encrypted: Bool, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task { Task {
if let user = ChatModel.shared.currentUser { if let user = m.currentUser {
await receiveFile(user, file.fileId, encrypted, false) await receiveFile(user, file.fileId, encrypted, false)
} }
} }
@@ -290,7 +291,7 @@ struct CIVideoView: View {
) )
.onAppear { .onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) { DispatchQueue.main.asyncAfter(deadline: .now()) {
ChatModel.shared.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
if let player = fullPlayer { if let player = fullPlayer {
player.play() player.play()
fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in fullScreenTimeObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: player.currentItem, queue: .main) { _ in
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct CIVoiceView: View { struct CIVoiceView: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
let recordingFile: CIFile? let recordingFile: CIFile?
let duration: Int let duration: Int
@@ -91,7 +92,7 @@ struct CIVoiceView: View {
} }
private func metaView() -> some View { private func metaView() -> some View {
CIMetaView(chatItem: chatItem) CIMetaView(chat: chat, chatItem: chatItem)
} }
} }
@@ -219,7 +220,7 @@ struct VoiceMessagePlayer: View {
private func downloadButton(_ recordingFile: CIFile) -> some View { private func downloadButton(_ recordingFile: CIFile) -> some View {
Button { Button {
Task { Task {
if let user = ChatModel.shared.currentUser { if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get()) await receiveFile(user: user, fileId: recordingFile.fileId, encrypted: privacyEncryptLocalFilesGroupDefault.get())
} }
} }
@@ -284,6 +285,7 @@ struct CIVoiceView_Previews: PreviewProvider {
) )
Group { Group {
CIVoiceView( CIVoiceView(
chat: Chat.sampleData,
chatItem: ChatItem.getVoiceMsgContentSample(), chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30, duration: 30,
@@ -292,12 +294,11 @@ struct CIVoiceView_Previews: PreviewProvider {
playbackTime: .constant(TimeInterval(20)), playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true) allowMenu: Binding.constant(true)
) )
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
} }
} }
@@ -11,6 +11,7 @@ import SimpleXChat
struct DeletedItemView: View { struct DeletedItemView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -18,7 +19,7 @@ struct DeletedItemView: View {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.italic() .italic()
CIMetaView(chatItem: chatItem) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
@@ -32,8 +33,8 @@ struct DeletedItemView: View {
struct DeletedItemView_Previews: PreviewProvider { struct DeletedItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
DeletedItemView(chatItem: ChatItem.getDeletedContentSample()) DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample())
DeletedItemView(chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData))) DeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(dir: .groupRcv(groupMember: GroupMember.sampleData)))
} }
.previewLayout(.fixed(width: 360, height: 200)) .previewLayout(.fixed(width: 360, height: 200))
} }
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct EmojiItemView: View { struct EmojiItemView: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
@@ -17,7 +18,7 @@ struct EmojiItemView: View {
emojiText(chatItem.content.text) emojiText(chatItem.content.text)
.padding(.top, 8) .padding(.top, 8)
.padding(.horizontal, 6) .padding(.horizontal, 6)
CIMetaView(chatItem: chatItem) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.bottom, 8) .padding(.bottom, 8)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@@ -32,8 +33,8 @@ func emojiText(_ text: String) -> Text {
struct EmojiItemView_Previews: PreviewProvider { struct EmojiItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ Group{
EmojiItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete))) EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete)))
EmojiItemView(chatItem: ChatItem.getSample(2, .directRcv, .now, "👍")) EmojiItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "👍"))
} }
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
} }
@@ -88,13 +88,12 @@ struct FramedCIVoiceView_Previews: PreviewProvider {
file: CIFile.getSample(fileStatus: .sndComplete) file: CIFile.getSample(fileStatus: .sndComplete)
) )
Group { Group {
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "Hello there", fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(text: "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."), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(text: "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."), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWithQuote, revealed: Binding.constant(false))
} }
.previewLayout(.fixed(width: 360, height: 360)) .previewLayout(.fixed(width: 360, height: 360))
.environmentObject(Chat.sampleData)
} }
} }
File diff suppressed because one or more lines are too long
@@ -150,7 +150,7 @@ struct FullScreenMediaView: View {
private func startPlayerAndNotify() { private func startPlayerAndNotify() {
if let player = player { if let player = player {
ChatModel.shared.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
player.play() player.play()
} }
} }
@@ -10,11 +10,12 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct IntegrityErrorItemView: View { struct IntegrityErrorItemView: View {
@ObservedObject var chat: Chat
var msgError: MsgErrorType var msgError: MsgErrorType
var chatItem: ChatItem var chatItem: ChatItem
var body: some View { var body: some View {
CIMsgError(chatItem: chatItem) { CIMsgError(chat: chat, chatItem: chatItem) {
switch msgError { switch msgError {
case .msgSkipped: case .msgSkipped:
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -52,6 +53,7 @@ struct IntegrityErrorItemView: View {
} }
struct CIMsgError: View { struct CIMsgError: View {
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var onTap: () -> Void var onTap: () -> Void
@@ -60,7 +62,7 @@ struct CIMsgError: View {
Text(chatItem.content.text) Text(chatItem.content.text)
.foregroundColor(.red) .foregroundColor(.red)
.italic() .italic()
CIMetaView(chatItem: chatItem) CIMetaView(chat: chat, chatItem: chatItem)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.padding(.leading, 12) .padding(.leading, 12)
@@ -74,6 +76,6 @@ struct CIMsgError: View {
struct IntegrityErrorItemView_Previews: PreviewProvider { struct IntegrityErrorItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
IntegrityErrorItemView(msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample()) IntegrityErrorItemView(chat: Chat.sampleData, msgError: .msgBadHash, chatItem: ChatItem.getIntegrityErrorSample())
} }
} }
@@ -10,39 +10,70 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct MarkedDeletedItemView: View { struct MarkedDeletedItemView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var body: some View { var body: some View {
HStack(alignment: .bottom, spacing: 0) { (Text(mergedMarkedDeletedText).italic() + Text(" ") + chatItem.timestampText)
if case let .moderated(_, byGroupMember) = chatItem.meta.itemDeleted { .font(.caption)
markedDeletedText("moderated by \(byGroupMember.chatViewName)") .foregroundColor(.secondary)
} else { .padding(.horizontal, 12)
markedDeletedText("marked deleted")
}
CIMetaView(chatItem: chatItem)
.padding(.horizontal, 12)
}
.padding(.leading, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, colorScheme)) .background(chatItemFrameColor(chatItem, colorScheme))
.cornerRadius(18) .cornerRadius(18)
.textSelection(.disabled) .textSelection(.disabled)
} }
func markedDeletedText(_ s: LocalizedStringKey) -> some View { var mergedMarkedDeletedText: LocalizedStringKey {
Text(s) if !revealed,
.font(.caption) let ciCategory = chatItem.mergeCategory,
.foregroundColor(.secondary) var i = m.getChatItemIndex(chatItem) {
.italic() var moderated = 0
.lineLimit(1) var blocked = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < m.reversedChatItems.count,
let ci = .some(m.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
case let .moderated(_, byGroupMember):
moderated += 1
moderatedBy.insert(byGroupMember.displayName)
case .blocked: blocked += 1
case .deleted: deleted += 1
}
i += 1
}
let total = moderated + blocked + deleted
return total <= 1
? markedDeletedText
: total == moderated
? "\(total) messages moderated by \(moderatedBy.joined(separator: ", "))"
: total == blocked
? "\(total) messages blocked"
: "\(total) messages marked deleted"
} else {
return markedDeletedText
}
}
var markedDeletedText: LocalizedStringKey {
switch chatItem.meta.itemDeleted {
case let .moderated(_, byGroupMember): "moderated by \(byGroupMember.displayName)"
case .blocked: "blocked"
default: "marked deleted"
}
} }
} }
struct MarkedDeletedItemView_Previews: PreviewProvider { struct MarkedDeletedItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group { Group {
MarkedDeletedItemView(chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))) MarkedDeletedItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(true))
} }
.previewLayout(.fixed(width: 360, height: 200)) .previewLayout(.fixed(width: 360, height: 200))
} }
@@ -25,7 +25,7 @@ private func typing(_ w: Font.Weight = .light) -> Text {
} }
struct MsgContentView: View { struct MsgContentView: View {
@EnvironmentObject var chat: Chat @ObservedObject var chat: Chat
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
var sender: String? = nil var sender: String? = nil
@@ -121,13 +121,11 @@ private func formatText(_ ft: FormattedText, _ preview: Bool) -> Text {
case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary) case .secret: return Text(t).foregroundColor(.clear).underline(color: .primary)
case let .colored(color): return Text(t).foregroundColor(color.uiColor) case let .colored(color): return Text(t).foregroundColor(color.uiColor)
case .uri: return linkText(t, t, preview, prefix: "") case .uri: return linkText(t, t, preview, prefix: "")
case let .simplexLink(linkType, simplexUri, trustedUri, smpHosts): case let .simplexLink(linkType, simplexUri, smpHosts):
switch privacySimplexLinkModeDefault.get() { switch privacySimplexLinkModeDefault.get() {
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "")
case .full: return linkText(t, simplexUri, preview, prefix: "") case .full: return linkText(t, simplexUri, preview, prefix: "")
case .browser: return trustedUri case .browser: return linkText(t, simplexUri, preview, prefix: "")
? linkText(t, t, preview, prefix: "")
: linkText(t, t, preview, prefix: "", color: .red, uiColor: .red)
} }
case .email: return linkText(t, t, preview, prefix: "mailto:") case .email: return linkText(t, t, preview, prefix: "mailto:")
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:")
@@ -154,6 +152,7 @@ struct MsgContentView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello") let chatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return MsgContentView( return MsgContentView(
chat: Chat.sampleData,
text: chatItem.text, text: chatItem.text,
formattedText: chatItem.formattedText, formattedText: chatItem.formattedText,
sender: chatItem.memberDisplayName, sender: chatItem.memberDisplayName,
@@ -10,6 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ChatItemInfoView: View { struct ChatItemInfoView: View {
@EnvironmentObject var chatModel: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var ci: ChatItem var ci: ChatItem
@Binding var chatItemInfo: ChatItemInfo? @Binding var chatItemInfo: ChatItemInfo?
@@ -290,8 +291,8 @@ struct ChatItemInfoView: View {
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] { private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus)] {
memberDeliveryStatuses.compactMap({ mds in memberDeliveryStatuses.compactMap({ mds in
if let mem = ChatModel.shared.groupMembers.first(where: { $0.groupMemberId == mds.groupMemberId }) { if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem, mds.memberDeliveryStatus) return (mem.wrapped, mds.memberDeliveryStatus)
} else { } else {
return nil return nil
} }
+79 -51
View File
@@ -10,7 +10,7 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct ChatItemView: View { struct ChatItemView: View {
var chatInfo: ChatInfo @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
var maxWidth: CGFloat = .infinity var maxWidth: CGFloat = .infinity
@State var scrollProxy: ScrollViewProxy? = nil @State var scrollProxy: ScrollViewProxy? = nil
@@ -19,8 +19,19 @@ struct ChatItemView: View {
@Binding var audioPlayer: AudioPlayer? @Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval? @Binding var playbackTime: TimeInterval?
init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding<Bool>, allowMenu: Binding<Bool> = .constant(false), audioPlayer: Binding<AudioPlayer?> = .constant(nil), playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback), playbackTime: Binding<TimeInterval?> = .constant(nil)) { init(
self.chatInfo = chatInfo chat: Chat,
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
scrollProxy: ScrollViewProxy? = nil,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
) {
self.chat = chat
self.chatItem = chatItem self.chatItem = chatItem
self.maxWidth = maxWidth self.maxWidth = maxWidth
_scrollProxy = .init(initialValue: scrollProxy) _scrollProxy = .init(initialValue: scrollProxy)
@@ -33,15 +44,15 @@ struct ChatItemView: View {
var body: some View { var body: some View {
let ci = chatItem let ci = chatItem
if chatItem.meta.itemDeleted != nil && !revealed { if chatItem.meta.itemDeleted != nil && (!revealed || chatItem.isDeletedContent) {
MarkedDeletedItemView(chatItem: chatItem) MarkedDeletedItemView(chat: chat, chatItem: chatItem, revealed: $revealed)
} else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive { } else if ci.quotedItem == nil && ci.meta.itemDeleted == nil && !ci.meta.isLive {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chatItem: ci) EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil { } else if ci.content.msgContent == nil {
ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else { } else {
framedItemView() framedItemView()
} }
@@ -51,15 +62,17 @@ struct ChatItemView: View {
} }
private func framedItemView() -> some View { private func framedItemView() -> some View {
FramedItemView(chatInfo: chatInfo, chatItem: chatItem, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) FramedItemView(chat: chat, chatItem: chatItem, revealed: $revealed, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime)
} }
} }
struct ChatItemContentView<Content: View>: View { struct ChatItemContentView<Content: View>: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
var chatInfo: ChatInfo @ObservedObject var chat: Chat
var chatItem: ChatItem var chatItem: ChatItem
@Binding var revealed: Bool
var msgContentView: () -> Content var msgContentView: () -> Content
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
var body: some View { var body: some View {
switch chatItem.content { switch chatItem.content {
@@ -69,11 +82,16 @@ struct ChatItemContentView<Content: View>: View {
case .rcvDeleted: deletedItemView() case .rcvDeleted: deletedItemView()
case let .sndCall(status, duration): callItemView(status, duration) case let .sndCall(status, duration): callItemView(status, duration)
case let .rcvCall(status, duration): callItemView(status, duration) case let .rcvCall(status, duration): callItemView(status, duration)
case let .rcvIntegrityError(msgError): IntegrityErrorItemView(msgError: msgError, chatItem: chatItem) case let .rcvIntegrityError(msgError):
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem) if developerTools {
IntegrityErrorItemView(chat: chat, msgError: msgError, chatItem: chatItem)
} else {
ZStack {}
}
case let .rcvDecryptionError(msgDecryptError, msgCount): CIRcvDecryptionError(chat: chat, msgDecryptError: msgDecryptError, msgCount: msgCount, chatItem: chatItem)
case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole)
case .rcvGroupEvent(.memberConnected): CIEventView(eventText: membersConnectedItemText) case .rcvDirectEvent: eventItemView()
case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem) case .rcvGroupEvent(.memberCreatedContact): CIMemberCreatedContactView(chatItem: chatItem)
case .rcvGroupEvent: eventItemView() case .rcvGroupEvent: eventItemView()
case .sndGroupEvent: eventItemView() case .sndGroupEvent: eventItemView()
@@ -82,9 +100,9 @@ struct ChatItemContentView<Content: View>: View {
case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .rcvChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor) case let .sndChatFeature(feature, enabled, _): chatFeatureView(feature, enabled.iconColor)
case let .rcvChatPreference(feature, allowed, param): case let .rcvChatPreference(feature, allowed, param):
CIFeaturePreferenceView(chatItem: chatItem, feature: feature, allowed: allowed, param: param) CIFeaturePreferenceView(chat: chat, chatItem: chatItem, feature: feature, allowed: allowed, param: param)
case let .sndChatPreference(feature, _, _): case let .sndChatPreference(feature, _, _):
CIChatFeatureView(chatItem: chatItem, feature: feature, icon: feature.icon, iconColor: .secondary) CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, icon: feature.icon, iconColor: .secondary)
case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .rcvGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor) case let .sndGroupFeature(feature, preference, _): chatFeatureView(feature, preference.enable.iconColor)
case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red) case let .rcvChatFeatureRejected(feature): chatFeatureView(feature, .red)
@@ -96,15 +114,15 @@ struct ChatItemContentView<Content: View>: View {
} }
private func deletedItemView() -> some View { private func deletedItemView() -> some View {
DeletedItemView(chatItem: chatItem) DeletedItemView(chat: chat, chatItem: chatItem)
} }
private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View { private func callItemView(_ status: CICallStatus, _ duration: Int) -> some View {
CICallItemView(chatInfo: chatInfo, chatItem: chatItem, status: status, duration: duration) CICallItemView(chat: chat, chatItem: chatItem, status: status, duration: duration)
} }
private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View {
CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chatInfo.incognito) CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole, chatIncognito: chat.chatInfo.incognito)
} }
private func eventItemView() -> some View { private func eventItemView() -> some View {
@@ -112,7 +130,9 @@ struct ChatItemContentView<Content: View>: View {
} }
private func eventItemViewText() -> Text { private func eventItemViewText() -> Text {
if let member = chatItem.memberDisplayName { if !revealed, let t = mergedGroupEventText {
return chatEventText(t + Text(" ") + chatItem.timestampText)
} else if let member = chatItem.memberDisplayName {
return Text(member + " ") return Text(member + " ")
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@@ -124,36 +144,44 @@ struct ChatItemContentView<Content: View>: View {
} }
private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View { private func chatFeatureView(_ feature: Feature, _ iconColor: Color) -> some View {
CIChatFeatureView(chatItem: chatItem, feature: feature, iconColor: iconColor) CIChatFeatureView(chatItem: chatItem, revealed: $revealed, feature: feature, iconColor: iconColor)
} }
private var membersConnectedItemText: Text { private var mergedGroupEventText: Text? {
if let t = membersConnectedText { let (count, ns) = chatModel.getConnectedMemberNames(chatItem)
return chatEventText(t, chatItem.timestampText) let members: LocalizedStringKey =
switch ns.count {
case 1: "\(ns[0]) connected"
case 2: "\(ns[0]) and \(ns[1]) connected"
case 3: "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
default:
ns.count > 3
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
: ""
}
return if count <= 1 {
nil
} else if ns.count == 0 {
Text("\(count) group events")
} else if count > ns.count {
Text(members) + Text(" ") + Text("and \(count - ns.count) other events")
} else { } else {
return eventItemViewText() Text(members)
} }
} }
private var membersConnectedText: LocalizedStringKey? {
let ns = chatModel.getConnectedMemberNames(chatItem)
return ns.count > 3
? "\(ns[0]), \(ns[1]) and \(ns.count - 2) other members connected"
: ns.count == 3
? "\(ns[0] + ", " + ns[1]) and \(ns[2]) connected"
: ns.count == 2
? "\(ns[0]) and \(ns[1]) connected"
: nil
}
} }
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text { func chatEventText(_ text: Text) -> Text {
(Text(eventText) + Text(" ") + ts) text
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.fontWeight(.light) .fontWeight(.light)
} }
func chatEventText(_ eventText: LocalizedStringKey, _ ts: Text) -> Text {
chatEventText(Text(eventText) + Text(" ") + ts)
}
func chatEventText(_ ci: ChatItem) -> Text { func chatEventText(_ ci: ChatItem) -> Text {
chatEventText("\(ci.content.text)", ci.timestampText) chatEventText("\(ci.content.text)", ci.timestampText)
} }
@@ -161,15 +189,15 @@ func chatEventText(_ ci: ChatItem) -> Text {
struct ChatItemView_Previews: PreviewProvider { struct ChatItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
Group{ Group{
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, .directRcv, .now, "🙂🙂🙂🙂🙂🙂"), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getDeletedContentSample(), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now)), revealed: Binding.constant(false))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "🙂", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true)) ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemLive: true), revealed: Binding.constant(true))
} }
.previewLayout(.fixed(width: 360, height: 70)) .previewLayout(.fixed(width: 360, height: 70))
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
@@ -181,7 +209,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil) let ciFeatureContent = CIContent.rcvChatFeature(feature: .fullDelete, enabled: FeatureEnabled(forUser: false, forContact: false), param: nil)
Group{ Group{
ChatItemView( ChatItemView(
chatInfo: ChatInfo.sampleData.direct, chat: Chat.sampleData,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -192,7 +220,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chatInfo: ChatInfo.sampleData.direct, chat: Chat.sampleData,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead), meta: CIMeta.getSample(1, .now, "1 skipped message", .rcvRead),
@@ -203,7 +231,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chatInfo: ChatInfo.sampleData.direct, chat: Chat.sampleData,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, "received invitation to join group team as admin", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -214,7 +242,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chatInfo: ChatInfo.sampleData.direct, chat: Chat.sampleData,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
@@ -225,7 +253,7 @@ struct ChatItemView_NonMsgContentDeleted_Previews: PreviewProvider {
revealed: Binding.constant(true) revealed: Binding.constant(true)
) )
ChatItemView( ChatItemView(
chatInfo: ChatInfo.sampleData.direct, chat: Chat.sampleData,
chatItem: ChatItem( chatItem: ChatItem(
chatDir: .directRcv, chatDir: .directRcv,
meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)), meta: CIMeta.getSample(1, .now, ciFeatureContent.text, .rcvRead, itemDeleted: .deleted(deletedTs: .now)),
+351 -167
View File
@@ -21,9 +21,7 @@ struct ChatView: View {
@State private var showChatInfoSheet: Bool = false @State private var showChatInfoSheet: Bool = false
@State private var showAddMembersSheet: Bool = false @State private var showAddMembersSheet: Bool = false
@State private var composeState = ComposeState() @State private var composeState = ComposeState()
@State private var deletingItem: ChatItem? = nil
@State private var keyboardVisible = false @State private var keyboardVisible = false
@State private var showDeleteMessage = false
@State private var connectionStats: ConnectionStats? @State private var connectionStats: ConnectionStats?
@State private var customUserProfile: Profile? @State private var customUserProfile: Profile?
@State private var connectionCode: String? @State private var connectionCode: String?
@@ -36,7 +34,12 @@ struct ChatView: View {
@State private var searchText: String = "" @State private var searchText: String = ""
@FocusState private var searchFocussed @FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon // opening GroupMemberInfoView on member icon
@State private var selectedMember: GroupMember? = nil @State private var membersLoaded = false
@State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
var body: some View { var body: some View {
if #available(iOS 16.0, *) { if #available(iOS 16.0, *) {
@@ -91,7 +94,10 @@ struct ChatView: View {
chatModel.chatId = nil chatModel.chatId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if chatModel.chatId == nil { if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:]
chatModel.reversedChatItems = [] chatModel.reversedChatItems = []
chatModel.groupMembers = []
membersLoaded = false
} }
} }
} }
@@ -129,18 +135,21 @@ struct ChatView: View {
} }
} else if case let .group(groupInfo) = cInfo { } else if case let .group(groupInfo) = cInfo {
Button { Button {
Task { Task { await loadGroupMembers(groupInfo) { showChatInfoSheet = true } }
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showChatInfoSheet = true
}
}
} label: { } label: {
ChatInfoToolbar(chat: chat) ChatInfoToolbar(chat: chat)
} }
.appSheet(isPresented: $showChatInfoSheet) { .appSheet(isPresented: $showChatInfoSheet) {
GroupChatInfoView(chat: chat, groupInfo: groupInfo) GroupChatInfoView(
chat: chat,
groupInfo: Binding(
get: { groupInfo },
set: { gInfo in
chat.chatInfo = .group(groupInfo: gInfo)
chat.created = Date.now
}
)
)
} }
} }
} }
@@ -150,7 +159,7 @@ struct ChatView: View {
HStack { HStack {
if contact.allowsFeature(.calls) { if contact.allowsFeature(.calls) {
callButton(contact, .audio, imageName: "phone") callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
} }
Menu { Menu {
if contact.allowsFeature(.calls) { if contact.allowsFeature(.calls) {
@@ -159,11 +168,11 @@ struct ChatView: View {
} label: { } label: {
Label("Video call", systemImage: "video") Label("Video call", systemImage: "video")
} }
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
} }
searchButton() searchButton()
toggleNtfsButton(chat) toggleNtfsButton(chat)
.disabled(!contact.ready) .disabled(!contact.ready || !contact.active)
} label: { } label: {
Image(systemName: "ellipsis") Image(systemName: "ellipsis")
} }
@@ -172,9 +181,16 @@ struct ChatView: View {
HStack { HStack {
if groupInfo.canAddMembers { if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) { if (chat.chatInfo.incognito) {
Image(systemName: "person.crop.circle.badge.plus") groupLinkButton()
.foregroundColor(Color(uiColor: .tertiaryLabel)) .appSheet(isPresented: $showGroupLinkSheet) {
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) } GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else { } else {
addMembersButton() addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) { .appSheet(isPresented: $showAddMembersSheet) {
@@ -196,6 +212,17 @@ struct ChatView: View {
} }
} }
private func loadGroupMembers(_ groupInfo: GroupInfo, updateView: @escaping () -> Void = {}) async {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatModel.chatId == groupInfo.id {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
membersLoaded = true
updateView()
}
}
}
private func initChatView() { private func initChatView() {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
if case let .direct(contact) = cInfo { if case let .direct(contact) = cInfo {
@@ -321,6 +348,7 @@ struct ChatView: View {
@ViewBuilder private func connectingText() -> some View { @ViewBuilder private func connectingText() -> some View {
if case let .direct(contact) = chat.chatInfo, if case let .direct(contact) = chat.chatInfo,
!contact.ready, !contact.ready,
contact.active,
!contact.nextSendGrpInv { !contact.nextSendGrpInv {
Text("connecting…") Text("connecting…")
.font(.caption) .font(.caption)
@@ -403,19 +431,32 @@ struct ChatView: View {
private func addMembersButton() -> some View { private func addMembersButton() -> some View {
Button { Button {
if case let .group(gInfo) = chat.chatInfo { if case let .group(gInfo) = chat.chatInfo {
Task { Task { await loadGroupMembers(gInfo) { showAddMembersSheet = true } }
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
showAddMembersSheet = true
}
}
} }
} label: { } label: {
Image(systemName: "person.crop.circle.badge.plus") Image(systemName: "person.crop.circle.badge.plus")
} }
} }
private func groupLinkButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
Task {
do {
if let link = try apiGetGroupLink(gInfo.groupId) {
(groupLink, groupLinkMemberRole) = link
}
} catch let error {
logger.error("ChatView apiGetGroupLink: \(responseError(error))")
}
showGroupLinkSheet = true
}
}
} label: {
Image(systemName: "link.badge.plus")
}
}
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) { private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id { if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
if loadingItems || firstPage { return } if loadingItems || firstPage { return }
@@ -445,73 +486,30 @@ struct ChatView: View {
} }
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View { @ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevItem, nextItem) = chatModel.getChatItemNeighbors(ci)
if ci.memberConnected != nil && nextItem?.memberConnected != nil {
// memberConnected events are aggregated at the last chat item in a row of such events, see ChatItemView
ZStack {} // scroll doesn't work if it's EmptyView()
} else {
if prevItem == nil || showMemberImage(member, prevItem) {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
Text(member.displayName)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, memberImageSize + 14)
.padding(.top, 7)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture { selectedMember = member }
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, member: member, navigation: true)
}
chatItemWithMenu(ci, maxWidth)
}
}
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, maxWidth)
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
}
} else {
chatItemWithMenu(ci, maxWidth)
.padding(.horizontal)
.padding(.top, 5)
}
}
private func chatItemWithMenu(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
ChatItemWithMenu( ChatItemWithMenu(
ci: ci, chat: chat,
chatItem: ci,
maxWidth: maxWidth, maxWidth: maxWidth,
scrollProxy: scrollProxy,
deleteMessage: deleteMessage,
deletingItem: $deletingItem,
composeState: $composeState, composeState: $composeState,
showDeleteMessage: $showDeleteMessage selectedMember: $selectedMember,
chatView: self
) )
.environmentObject(chat)
} }
private struct ChatItemWithMenu: View { private struct ChatItemWithMenu: View {
@EnvironmentObject var chat: Chat @EnvironmentObject var m: ChatModel
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var ci: ChatItem @ObservedObject var chat: Chat
var chatItem: ChatItem
var maxWidth: CGFloat var maxWidth: CGFloat
var scrollProxy: ScrollViewProxy?
var deleteMessage: (CIDeleteMode) -> Void
@Binding var deletingItem: ChatItem?
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var showDeleteMessage: Bool @Binding var selectedMember: GMember?
var chatView: ChatView
@State private var deletingItem: ChatItem? = nil
@State private var showDeleteMessage = false
@State private var deletingItems: [Int64] = []
@State private var showDeleteMessages = false
@State private var revealed = false @State private var revealed = false
@State private var showChatItemInfoSheet: Bool = false @State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo? @State private var chatItemInfo: ChatItemInfo?
@@ -523,18 +521,114 @@ struct ChatView: View {
@State private var playbackTime: TimeInterval? @State private var playbackTime: TimeInterval?
var body: some View { var body: some View {
let (currIndex, nextItem) = m.getNextChatItem(chatItem)
let ciCategory = chatItem.mergeCategory
if (ciCategory != nil && ciCategory == nextItem?.mergeCategory) {
// memberConnected events and deleted items are aggregated at the last chat item in a row, see ChatItemView
ZStack {} // scroll doesn't work if it's EmptyView()
} else {
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden)
if revealed, let range = range {
let items = Array(zip(Array(range), m.reversedChatItems[range]))
ForEach(items, id: \.1.viewId) { (i, ci) in
let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1]
chatItemView(ci, nil, prev)
}
} else {
chatItemView(chatItem, range, prevItem)
}
}
}
@ViewBuilder func chatItemView(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ prevItem: ChatItem?) -> some View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) =
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
Text(memberNames(member, prevMember, memCount))
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, memberImageSize + 14)
.padding(.top, 7)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image)
.frame(width: memberImageSize, height: memberImageSize)
.onTapGesture {
if chatView.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await chatView.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
}
}
}
}
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
}
}
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.top, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
.padding(.top, 5)
}
}
private func memberNames(_ member: GroupMember, _ prevMember: GroupMember?, _ memCount: Int) -> LocalizedStringKey {
let name = member.displayName
return if let prevName = prevMember?.displayName {
memCount > 2
? "\(name), \(prevName) and \(memCount - 2) members"
: "\(name) and \(prevName)"
} else {
"\(name)"
}
}
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
let uiMenu: Binding<UIMenu> = Binding( let uiMenu: Binding<UIMenu> = Binding(
get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) }, get: { UIMenu(title: "", children: menu(ci, range, live: composeState.liveMessage != nil)) },
set: { _ in } set: { _ in }
) )
VStack(alignment: alignment.horizontal, spacing: 3) { VStack(alignment: alignment.horizontal, spacing: 3) {
ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) ChatItemView(
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) chat: chat,
.accessibilityLabel("") chatItem: ci,
maxWidth: maxWidth,
scrollProxy: chatView.scrollProxy,
revealed: $revealed,
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
)
.uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu)
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 { if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
chatItemReactions() chatItemReactions(ci)
.padding(.bottom, 4) .padding(.bottom, 4)
} }
} }
@@ -548,6 +642,11 @@ struct ChatView: View {
} }
} }
} }
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessages()
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.onDisappear { .onDisappear {
@@ -565,7 +664,15 @@ struct ChatView: View {
} }
} }
private func chatItemReactions() -> some View { private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool {
switch (prevItem?.chatDir) {
case .groupSnd: return true
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId
default: return false
}
}
private func chatItemReactions(_ ci: ChatItem) -> some View {
HStack(spacing: 4) { HStack(spacing: 4) {
ForEach(ci.reactions, id: \.reaction) { r in ForEach(ci.reactions, id: \.reaction) { r in
let v = HStack(spacing: 4) { let v = HStack(spacing: 4) {
@@ -585,7 +692,7 @@ struct ChatView: View {
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) { if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
v.onTapGesture { v.onTapGesture {
setReaction(add: !r.userReacted, reaction: r.reaction) setReaction(ci, add: !r.userReacted, reaction: r.reaction)
} }
} else { } else {
v v
@@ -594,10 +701,10 @@ struct ChatView: View {
} }
} }
private func menu(live: Bool) -> [UIMenuElement] { private func menu(_ ci: ChatItem, _ range: ClosedRange<Int>?, live: Bool) -> [UIMenuElement] {
var menu: [UIMenuElement] = [] var menu: [UIMenuElement] = []
if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed {
let rs = allReactions() let rs = allReactions(ci)
if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction, if chat.chatInfo.featureEnabled(.reactions) && ci.allowAddReaction,
rs.count > 0 { rs.count > 0 {
var rm: UIMenu var rm: UIMenu
@@ -614,10 +721,10 @@ struct ChatView: View {
menu.append(rm) menu.append(rm)
} }
if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live { if ci.meta.itemDeleted == nil && !ci.isLiveDummy && !live {
menu.append(replyUIAction()) menu.append(replyUIAction(ci))
} }
menu.append(shareUIAction()) menu.append(shareUIAction(ci))
menu.append(copyUIAction()) menu.append(copyUIAction(ci))
if let fileSource = getLoadedFileSource(ci.file) { if let fileSource = getLoadedFileSource(ci.file) {
if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) { if case .image = ci.content.msgContent, let image = getLoadedImage(ci.file) {
if image.imageData != nil { if image.imageData != nil {
@@ -630,9 +737,9 @@ struct ChatView: View {
} }
} }
if ci.meta.editable && !mc.isVoice && !live { if ci.meta.editable && !mc.isVoice && !live {
menu.append(editAction()) menu.append(editAction(ci))
} }
menu.append(viewInfoUIAction()) menu.append(viewInfoUIAction(ci))
if revealed { if revealed {
menu.append(hideUIAction()) menu.append(hideUIAction())
} }
@@ -642,25 +749,31 @@ struct ChatView: View {
menu.append(cancelFileUIAction(file.fileId, cancelAction)) menu.append(cancelFileUIAction(file.fileId, cancelAction))
} }
if !live || !ci.meta.isLive { if !live || !ci.meta.isLive {
menu.append(deleteUIAction()) menu.append(deleteUIAction(ci))
} }
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) { if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
menu.append(moderateUIAction(groupInfo)) menu.append(moderateUIAction(ci, groupInfo))
} }
} else if ci.meta.itemDeleted != nil { } else if ci.meta.itemDeleted != nil {
if !ci.isDeletedContent { if revealed {
menu.append(hideUIAction())
} else if !ci.isDeletedContent {
menu.append(revealUIAction()) menu.append(revealUIAction())
} else if range != nil {
menu.append(expandUIAction())
} }
menu.append(viewInfoUIAction()) menu.append(viewInfoUIAction(ci))
menu.append(deleteUIAction()) menu.append(deleteUIAction(ci))
} else if ci.isDeletedContent { } else if ci.isDeletedContent {
menu.append(viewInfoUIAction()) menu.append(viewInfoUIAction(ci))
menu.append(deleteUIAction()) menu.append(deleteUIAction(ci))
} else if ci.mergeCategory != nil {
menu.append(revealed ? shrinkUIAction() : expandUIAction())
} }
return menu return menu
} }
private func replyUIAction() -> UIAction { private func replyUIAction(_ ci: ChatItem) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Reply", comment: "chat item action"), title: NSLocalizedString("Reply", comment: "chat item action"),
image: UIImage(systemName: "arrowshape.turn.up.left") image: UIImage(systemName: "arrowshape.turn.up.left")
@@ -695,11 +808,11 @@ struct ChatView: View {
) )
} }
private func allReactions() -> [UIAction] { private func allReactions(_ ci: ChatItem) -> [UIAction] {
MsgReaction.values.compactMap { r in MsgReaction.values.compactMap { r in
ci.reactions.contains(where: { $0.userReacted && $0.reaction == r }) ci.reactions.contains(where: { $0.userReacted && $0.reaction == r })
? nil ? nil
: UIAction(title: r.text) { _ in setReaction(add: true, reaction: r) } : UIAction(title: r.text) { _ in setReaction(ci, add: true, reaction: r) }
} }
} }
@@ -707,7 +820,7 @@ struct ChatView: View {
rs.count > 4 ? 3 : 4 rs.count > 4 ? 3 : 4
} }
private func setReaction(add: Bool, reaction: MsgReaction) { private func setReaction(_ ci: ChatItem, add: Bool, reaction: MsgReaction) {
Task { Task {
do { do {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
@@ -719,7 +832,7 @@ struct ChatView: View {
reaction: reaction reaction: reaction
) )
await MainActor.run { await MainActor.run {
ChatModel.shared.updateChatItem(chat.chatInfo, chatItem) m.updateChatItem(chat.chatInfo, chatItem)
} }
} catch let error { } catch let error {
logger.error("apiChatItemReaction error: \(responseError(error))") logger.error("apiChatItemReaction error: \(responseError(error))")
@@ -727,7 +840,7 @@ struct ChatView: View {
} }
} }
private func shareUIAction() -> UIAction { private func shareUIAction(_ ci: ChatItem) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Share", comment: "chat item action"), title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up") image: UIImage(systemName: "square.and.arrow.up")
@@ -740,7 +853,7 @@ struct ChatView: View {
} }
} }
private func copyUIAction() -> UIAction { private func copyUIAction(_ ci: ChatItem) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"), title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc") image: UIImage(systemName: "doc.on.doc")
@@ -773,7 +886,7 @@ struct ChatView: View {
} }
} }
private func editAction() -> UIAction { private func editAction(_ ci: ChatItem) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Edit", comment: "chat item action"), title: NSLocalizedString("Edit", comment: "chat item action"),
image: UIImage(systemName: "square.and.pencil") image: UIImage(systemName: "square.and.pencil")
@@ -784,7 +897,7 @@ struct ChatView: View {
} }
} }
private func viewInfoUIAction() -> UIAction { private func viewInfoUIAction(_ ci: ChatItem) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Info", comment: "chat item action"), title: NSLocalizedString("Info", comment: "chat item action"),
image: UIImage(systemName: "info.circle") image: UIImage(systemName: "info.circle")
@@ -797,10 +910,7 @@ struct ChatView: View {
chatItemInfo = ciInfo chatItemInfo = ciInfo
} }
if case let .group(gInfo) = chat.chatInfo { if case let .group(gInfo) = chat.chatInfo {
let groupMembers = await apiListMembers(gInfo.groupId) await chatView.loadGroupMembers(gInfo)
await MainActor.run {
ChatModel.shared.groupMembers = groupMembers
}
} }
} catch let error { } catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))") logger.error("apiGetChatItemInfo error: \(responseError(error))")
@@ -821,7 +931,7 @@ struct ChatView: View {
message: Text(cancelAction.alert.message), message: Text(cancelAction.alert.message),
primaryButton: .destructive(Text(cancelAction.alert.confirm)) { primaryButton: .destructive(Text(cancelAction.alert.confirm)) {
Task { Task {
if let user = ChatModel.shared.currentUser { if let user = m.currentUser {
await cancelFile(user: user, fileId: fileId) await cancelFile(user: user, fileId: fileId)
} }
} }
@@ -842,18 +952,45 @@ struct ChatView: View {
} }
} }
private func deleteUIAction() -> UIAction { private func deleteUIAction(_ ci: ChatItem) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Delete", comment: "chat item action"), title: NSLocalizedString("Delete", comment: "chat item action"),
image: UIImage(systemName: "trash"), image: UIImage(systemName: "trash"),
attributes: [.destructive] attributes: [.destructive]
) { _ in ) { _ in
showDeleteMessage = true if !revealed && ci.meta.itemDeleted != nil,
deletingItem = ci let currIndex = m.getChatItemIndex(ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
var itemIds: [Int64] = []
for i in range {
itemIds.append(m.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
} else {
showDeleteMessage = true
deletingItem = ci
}
} else {
showDeleteMessage = true
deletingItem = ci
}
} }
} }
private func moderateUIAction(_ groupInfo: GroupInfo) -> UIAction { private func itemsRange(_ currIndex: Int?, _ prevHidden: Int?) -> ClosedRange<Int>? {
if let currIndex = currIndex,
let prevHidden = prevHidden,
prevHidden > currIndex {
currIndex...prevHidden
} else {
nil
}
}
private func moderateUIAction(_ ci: ChatItem, _ groupInfo: GroupInfo) -> UIAction {
UIAction( UIAction(
title: NSLocalizedString("Moderate", comment: "chat item action"), title: NSLocalizedString("Moderate", comment: "chat item action"),
image: UIImage(systemName: "flag"), image: UIImage(systemName: "flag"),
@@ -885,20 +1022,105 @@ struct ChatView: View {
} }
} }
} }
private func expandUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Expand", comment: "chat item action"),
image: UIImage(systemName: "arrow.up.and.line.horizontal.and.arrow.down")
) { _ in
withAnimation {
revealed = true
}
}
}
private func shrinkUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("Hide", comment: "chat item action"),
image: UIImage(systemName: "arrow.down.and.line.horizontal.and.arrow.up")
) { _ in
withAnimation {
revealed = false
}
}
}
private var broadcastDeleteButtonText: LocalizedStringKey { private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
} }
}
private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { var deleteMessagesTitle: LocalizedStringKey {
switch (prevItem?.chatDir) { let n = deletingItems.count
case .groupSnd: return true return n == 1 ? "Delete message?" : "Delete \(n) messages?"
case let .groupRcv(prevMember): return prevMember.groupMemberId != member.groupMemberId }
default: return false
private func deleteMessages() {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
var deletedItems: [ChatItem] = []
for itemId in itemIds {
do {
let (di, _) = try await apiDeleteChatItem(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemId: itemId,
mode: .cidmInternal
)
deletedItems.append(di)
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
await MainActor.run {
for di in deletedItems {
m.removeChatItem(chatInfo, di)
}
}
}
}
}
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
var deletedItem: ChatItem
var toItem: ChatItem?
if case .cidmBroadcast = mode,
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
groupId: groupInfo.apiId,
groupMemberId: groupMember.groupMemberId,
itemId: di.id
)
} else {
(deletedItem, toItem) = try await apiDeleteChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: di.id,
mode: mode
)
}
DispatchQueue.main.async {
deletingItem = nil
if let toItem = toItem {
_ = m.upsertChatItem(chat.chatInfo, toItem)
} else {
m.removeChatItem(chat.chatInfo, deletedItem)
}
}
}
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
} }
} }
private func scrollToBottom(_ proxy: ScrollViewProxy) { private func scrollToBottom(_ proxy: ScrollViewProxy) {
if let ci = chatModel.reversedChatItems.first { if let ci = chatModel.reversedChatItems.first {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
@@ -910,44 +1132,6 @@ struct ChatView: View {
withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) } withAnimation { proxy.scrollTo(ci.viewId, anchor: .top) }
} }
} }
private func deleteMessage(_ mode: CIDeleteMode) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
var deletedItem: ChatItem
var toItem: ChatItem?
if case .cidmBroadcast = mode,
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
groupId: groupInfo.apiId,
groupMemberId: groupMember.groupMemberId,
itemId: di.id
)
} else {
(deletedItem, toItem) = try await apiDeleteChatItem(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: di.id,
mode: mode
)
}
DispatchQueue.main.async {
deletingItem = nil
if let toItem = toItem {
_ = chatModel.upsertChatItem(chat.chatInfo, toItem)
} else {
chatModel.removeChatItem(chat.chatInfo, deletedItem)
}
}
}
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
}
} }
@ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View { @ViewBuilder func toggleNtfsButton(_ chat: Chat) -> some View {
@@ -964,7 +1148,7 @@ struct ChatView: View {
func toggleNotifications(_ chat: Chat, enableNtfs: Bool) { func toggleNotifications(_ chat: Chat, enableNtfs: Bool) {
var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults var chatSettings = chat.chatInfo.chatSettings ?? ChatSettings.defaults
chatSettings.enableNtfs = enableNtfs chatSettings.enableNtfs = enableNtfs ? .all : .none
updateChatSettings(chat, chatSettings: chatSettings) updateChatSettings(chat, chatSettings: chatSettings)
} }
@@ -592,12 +592,14 @@ struct ComposeView: View {
EmptyView() EmptyView()
case let .quotedItem(chatItem: quotedItem): case let .quotedItem(chatItem: quotedItem):
ContextItemView( ContextItemView(
chat: chat,
contextItem: quotedItem, contextItem: quotedItem,
contextIcon: "arrowshape.turn.up.left", contextIcon: "arrowshape.turn.up.left",
cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) } cancelContextItem: { composeState = composeState.copy(contextItem: .noContextItem) }
) )
case let .editingItem(chatItem: editingItem): case let .editingItem(chatItem: editingItem):
ContextItemView( ContextItemView(
chat: chat,
contextItem: editingItem, contextItem: editingItem,
contextIcon: "pencil", contextIcon: "pencil",
cancelContextItem: { clearState() } cancelContextItem: { clearState() }
@@ -11,6 +11,7 @@ import SimpleXChat
struct ContextItemView: View { struct ContextItemView: View {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
@ObservedObject var chat: Chat
let contextItem: ChatItem let contextItem: ChatItem
let contextIcon: String let contextIcon: String
let cancelContextItem: () -> Void let cancelContextItem: () -> Void
@@ -48,6 +49,7 @@ struct ContextItemView: View {
private func msgContentView(lines: Int) -> some View { private func msgContentView(lines: Int) -> some View {
MsgContentView( MsgContentView(
chat: chat,
text: contextItem.text, text: contextItem.text,
formattedText: contextItem.formattedText formattedText: contextItem.formattedText
) )
@@ -59,6 +61,6 @@ struct ContextItemView: View {
struct ContextItemView_Previews: PreviewProvider { struct ContextItemView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello") let contextItem: ChatItem = ChatItem.getSample(1, .directSnd, .now, "hello")
return ContextItemView(contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {}) return ContextItemView(chat: Chat.sampleData, contextItem: contextItem, contextIcon: "pencil.circle", cancelContextItem: {})
} }
} }
@@ -14,20 +14,28 @@ import PhotosUI
struct NativeTextEditor: UIViewRepresentable { struct NativeTextEditor: UIViewRepresentable {
@Binding var text: String @Binding var text: String
@Binding var disableEditing: Bool @Binding var disableEditing: Bool
let height: CGFloat @Binding var height: CGFloat
let font: UIFont
@Binding var focused: Bool @Binding var focused: Bool
let alignment: TextAlignment let alignment: TextAlignment
let onImagesAdded: ([UploadContent]) -> Void let onImagesAdded: ([UploadContent]) -> Void
private let minHeight: CGFloat = 37
private let defaultHeight: CGFloat = {
let field = CustomUITextField(height: Binding.constant(0))
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
}()
func makeUIView(context: Context) -> UITextView { func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField() let field = CustomUITextField(height: _height)
field.text = text field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right field.textAlignment = alignment == .leading ? .left : .right
field.autocapitalizationType = .sentences field.autocapitalizationType = .sentences
field.setOnTextChangedListener { newText, images in field.setOnTextChangedListener { newText, images in
if !disableEditing { if !disableEditing {
// Speed up the process of updating layout, reduce jumping content on screen
if !isShortEmoji(newText) { updateHeight(field) }
text = newText text = newText
} else { } else {
field.text = text field.text = text
@@ -39,24 +47,72 @@ struct NativeTextEditor: UIViewRepresentable {
field.setOnFocusChangedListener { focused = $0 } field.setOnFocusChangedListener { focused = $0 }
field.delegate = field field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4) field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
updateFont(field)
updateHeight(field)
return field return field
} }
func updateUIView(_ field: UITextView, context: Context) { func updateUIView(_ field: UITextView, context: Context) {
field.text = text field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right field.textAlignment = alignment == .leading ? .left : .right
updateFont(field)
updateHeight(field)
}
private func updateHeight(_ field: UITextView) {
let maxHeight = min(360, field.font!.lineHeight * 12)
// When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
let newHeight = field.text == ""
? defaultHeight
: min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
if field.frame.size.height != newHeight {
field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
(field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
}
}
private func updateFont(_ field: UITextView) {
field.font = isShortEmoji(field.text)
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
: UIFont.preferredFont(forTextStyle: .body)
} }
} }
private class CustomUITextField: UITextView, UITextViewDelegate { private class CustomUITextField: UITextView, UITextViewDelegate {
var height: Binding<CGFloat>
var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in } var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in } var onFocusChanged: (Bool) -> Void = { focused in }
init(height: Binding<CGFloat>) {
self.height = height
super.init(frame: .zero, textContainer: nil)
}
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
// This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft),
// produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call
// (who knows why...)
func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) {
self.newHeight = newHeight
invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: CGSize {
if height.wrappedValue != newHeight {
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
}
return CGSizeMake(0, newHeight)
}
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) { func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
self.onTextChanged = onTextChanged self.onTextChanged = onTextChanged
} }
func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) { func setOnFocusChangedListener(onFocusChanged: @escaping (Bool) -> Void) {
self.onFocusChanged = onFocusChanged self.onFocusChanged = onFocusChanged
} }
@@ -144,14 +200,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
struct NativeTextEditor_Previews: PreviewProvider{ struct NativeTextEditor_Previews: PreviewProvider{
static var previews: some View { static var previews: some View {
return NativeTextEditor( NativeTextEditor(
text: Binding.constant("Hello, world!"), text: Binding.constant("Hello, world!"),
disableEditing: Binding.constant(false), disableEditing: Binding.constant(false),
height: 100, height: Binding.constant(100),
font: UIFont.preferredFont(forTextStyle: .body),
focused: Binding.constant(false), focused: Binding.constant(false),
alignment: TextAlignment.leading, alignment: TextAlignment.leading,
onImagesAdded: { _ in } onImagesAdded: { _ in }
) )
.fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -32,15 +32,12 @@ struct SendMessageView: View {
var sendButtonColor = Color.accentColor var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = 42 @State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body @State private var teFont: Font = .body
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
@State private var sendButtonSize: CGFloat = 29 @State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1 @State private var sendButtonOpacity: CGFloat = 1
@State private var showCustomDisappearingMessageDialogue = false @State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false @State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false @State private var progressByTimeout = false
var maxHeight: CGFloat = 360
var minHeight: CGFloat = 37
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View { var body: some View {
@@ -57,30 +54,16 @@ struct SendMessageView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { } else {
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
Text(composeState.message)
.lineLimit(10)
.font(teFont)
.multilineTextAlignment(alignment)
// put text on top (after NativeTextEditor) and set color to precisely align it on changes
// .foregroundColor(.red)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 6)
.matchedGeometryEffect(id: "te", in: namespace)
.background(GeometryReader(content: updateHeight))
NativeTextEditor( NativeTextEditor(
text: $composeState.message, text: $composeState.message,
disableEditing: $composeState.inProgress, disableEditing: $composeState.inProgress,
height: teHeight, height: $teHeight,
font: teUiFont,
focused: $keyboardVisible, focused: $keyboardVisible,
alignment: alignment, alignment: alignment,
onImagesAdded: onMediaAdded onImagesAdded: onMediaAdded
) )
.allowsTightening(false) .allowsTightening(false)
.frame(height: teHeight) .fixedSize(horizontal: false, vertical: true)
} }
} }
@@ -100,11 +83,13 @@ struct SendMessageView: View {
.frame(height: teHeight, alignment: .bottom) .frame(height: teHeight, alignment: .bottom)
} }
} }
.padding(.vertical, 1)
.overlay(
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20)) RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true) .strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
.frame(height: teHeight) )
} }
.onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in .onChange(of: composeState.inProgress) { inProgress in
if inProgress { if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
@@ -415,16 +400,12 @@ struct SendMessageView: View {
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
} }
private func updateHeight(_ g: GeometryProxy) -> Color { private func updateFont(_ text: String) {
DispatchQueue.main.async { DispatchQueue.main.async {
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight) teFont = isShortEmoji(text)
(teFont, teUiFont) = isShortEmoji(composeState.message) ? (text.count < 4 ? largeEmojiFont : mediumEmojiFont)
? composeState.message.count < 4 : .body
? (largeEmojiFont, largeEmojiUIFont)
: (mediumEmojiFont, mediumEmojiUIFont)
: (.body, UIFont.preferredFont(forTextStyle: .body))
} }
return Color.clear
} }
} }
@@ -144,7 +144,7 @@ struct AddGroupMembersViewCommon: View {
do { do {
for contactId in selectedContacts { for contactId in selectedContacts {
let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole) let member = try await apiAddMember(groupInfo.groupId, contactId, selectedRole)
await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, member) } await MainActor.run { _ = chatModel.upsertGroupMember(groupInfo, member) }
} }
addedMembersCb(selectedContacts) addedMembersCb(selectedContacts)
} catch { } catch {
@@ -15,7 +15,7 @@ struct GroupChatInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@State var groupInfo: GroupInfo @Binding var groupInfo: GroupInfo
@ObservedObject private var alertManager = AlertManager.shared @ObservedObject private var alertManager = AlertManager.shared
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: String?
@@ -35,14 +35,30 @@ struct GroupChatInfoView: View {
case leaveGroupAlert case leaveGroupAlert
case cantInviteIncognitoAlert case cantInviteIncognitoAlert
case largeGroupReceiptsDisabled case largeGroupReceiptsDisabled
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
var id: GroupChatInfoViewAlert { get { self } } var id: String {
switch self {
case .deleteGroupAlert: return "deleteGroupAlert"
case .clearChatAlert: return "clearChatAlert"
case .leaveGroupAlert: return "leaveGroupAlert"
case .cantInviteIncognitoAlert: return "cantInviteIncognitoAlert"
case .largeGroupReceiptsDisabled: return "largeGroupReceiptsDisabled"
case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .error(title, _): return "error \(title)"
}
}
} }
var body: some View { var body: some View {
NavigationView { NavigationView {
let members = chatModel.groupMembers let members = chatModel.groupMembers
.filter { $0.memberStatus != .memLeft && $0.memberStatus != .memRemoved } .filter { m in let status = m.wrapped.memberStatus; return status != .memLeft && status != .memRemoved }
.sorted { $0.displayName.lowercased() < $1.displayName.lowercased() } .sorted { $0.displayName.lowercased() < $1.displayName.lowercased() }
List { List {
@@ -57,7 +73,7 @@ struct GroupChatInfoView: View {
addOrEditWelcomeMessage() addOrEditWelcomeMessage()
} }
groupPreferencesButton($groupInfo) groupPreferencesButton($groupInfo)
if members.filter({ $0.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT { if members.filter({ $0.wrapped.memberCurrent }).count <= SMALL_GROUPS_RCPS_MEM_LIMIT {
sendReceiptsOption() sendReceiptsOption()
} else { } else {
sendReceiptsOptionDisabled() sendReceiptsOptionDisabled()
@@ -84,17 +100,17 @@ struct GroupChatInfoView: View {
.padding(.leading, 8) .padding(.leading, 8)
} }
let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase let s = searchText.trimmingCharacters(in: .whitespaces).localizedLowercase
let filteredMembers = s == "" ? members : members.filter { $0.chatViewName.localizedLowercase.contains(s) } let filteredMembers = s == "" ? members : members.filter { $0.wrapped.chatViewName.localizedLowercase.contains(s) }
memberView(groupInfo.membership, user: true) MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
ForEach(filteredMembers) { member in ForEach(filteredMembers) { member in
ZStack { ZStack {
NavigationLink { NavigationLink {
memberInfoView(member.groupMemberId) memberInfoView(member)
} label: { } label: {
EmptyView() EmptyView()
} }
.opacity(0) .opacity(0)
memberView(member) MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
} }
} }
} }
@@ -126,6 +142,10 @@ struct GroupChatInfoView: View {
case .leaveGroupAlert: return leaveGroupAlert() case .leaveGroupAlert: return leaveGroupAlert()
case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert() case .cantInviteIncognitoAlert: return cantInviteIncognitoAlert()
case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert() case .largeGroupReceiptsDisabled: return largeGroupReceiptsDisabledAlert()
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
} }
} }
.onAppear { .onAppear {
@@ -174,7 +194,7 @@ struct GroupChatInfoView: View {
Task { Task {
let groupMembers = await apiListMembers(groupInfo.groupId) let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run { await MainActor.run {
ChatModel.shared.groupMembers = groupMembers chatModel.groupMembers = groupMembers.map { GMember.init($0) }
} }
} }
} }
@@ -183,51 +203,92 @@ struct GroupChatInfoView: View {
} }
} }
private func memberView(_ member: GroupMember, user: Bool = false) -> some View { private struct MemberRowView: View {
HStack{ var groupInfo: GroupInfo
ProfileImage(imageStr: member.image) @ObservedObject var groupMember: GMember
.frame(width: 38, height: 38) var user: Bool = false
.padding(.trailing, 2) @Binding var alert: GroupChatInfoViewAlert?
// TODO server connection status
VStack(alignment: .leading) { var body: some View {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary) let member = groupMember.wrapped
(member.verified ? memberVerifiedShield + t : t) let v = HStack{
.lineLimit(1) ProfileImage(imageStr: member.image)
let s = Text(member.memberStatus.shortText) .frame(width: 38, height: 38)
(user ? Text ("you: ") + s : s) .padding(.trailing, 2)
.lineLimit(1) // TODO server connection status
.font(.caption) VStack(alignment: .leading) {
.foregroundColor(.secondary) let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : .primary)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
.lineLimit(1)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
let role = member.memberRole
if role == .owner || role == .admin {
Text(member.memberRole.text)
.foregroundColor(.secondary)
}
} }
Spacer()
let role = member.memberRole if user {
if role == .owner || role == .admin { v
Text(member.memberRole.text) } else if member.canBeRemoved(groupInfo: groupInfo) {
.foregroundColor(.secondary) removeSwipe(member, blockSwipe(member, v))
} else {
blockSwipe(member, v)
}
}
private func blockSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .leading) {
if member.memberSettings.showMessages {
Button {
alert = .blockMemberAlert(mem: member)
} label: {
Label("Block member", systemImage: "hand.raised").foregroundColor(.secondary)
}
} else {
Button {
alert = .unblockMemberAlert(mem: member)
} label: {
Label("Unblock member", systemImage: "hand.raised.slash").foregroundColor(.accentColor)
}
}
}
}
private func removeSwipe<V: View>(_ member: GroupMember, _ v: V) -> some View {
v.swipeActions(edge: .trailing) {
Button(role: .destructive) {
alert = .removeMemberAlert(mem: member)
} label: {
Label("Remove member", systemImage: "trash")
.foregroundColor(Color.red)
}
} }
} }
} }
private var memberVerifiedShield: Text { private func memberInfoView(_ groupMember: GMember) -> some View {
(Text(Image(systemName: "checkmark.shield")) + Text(" ")) GroupMemberInfoView(groupInfo: groupInfo, groupMember: groupMember)
.font(.caption) .navigationBarHidden(false)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
@ViewBuilder private func memberInfoView(_ groupMemberId: Int64?) -> some View {
if let mId = groupMemberId, let member = chatModel.groupMembers.first(where: { $0.groupMemberId == mId }) {
GroupMemberInfoView(groupInfo: groupInfo, member: member)
.navigationBarHidden(false)
}
} }
private func groupLinkButton() -> some View { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole) GroupLinkView(
.navigationBarTitle("Group link") groupId: groupInfo.groupId,
.navigationBarTitleDisplayMode(.large) groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: { } label: {
if groupLink == nil { if groupLink == nil {
Label("Create group link", systemImage: "link.badge.plus") Label("Create group link", systemImage: "link.badge.plus")
@@ -375,6 +436,28 @@ struct GroupChatInfoView: View {
alert = .largeGroupReceiptsDisabled alert = .largeGroupReceiptsDisabled
} }
} }
private func removeMemberAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Remove member?"),
message: Text("Member will be removed from group - this cannot be undone!"),
primaryButton: .destructive(Text("Remove")) {
Task {
do {
let updatedMember = try await apiRemoveMember(groupInfo.groupId, mem.groupMemberId)
await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, updatedMember)
}
} catch let error {
logger.error("apiRemoveMember error: \(responseError(error))")
let a = getErrorAlert(error, "Error removing member")
alert = .error(title: a.title, error: a.message)
}
}
},
secondaryButton: .cancel()
)
}
} }
func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View { func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bool = false) -> some View {
@@ -396,6 +479,14 @@ func groupPreferencesButton(_ groupInfo: Binding<GroupInfo>, _ creatingGroup: Bo
} }
} }
private var memberVerifiedShield: Text {
(Text(Image(systemName: "checkmark.shield")) + Text(" "))
.font(.caption)
.baselineOffset(2)
.kerning(-2)
.foregroundColor(.secondary)
}
func cantInviteIncognitoAlert() -> Alert { func cantInviteIncognitoAlert() -> Alert {
Alert( Alert(
title: Text("Can't invite contacts!"), title: Text("Can't invite contacts!"),
@@ -412,6 +503,9 @@ func largeGroupReceiptsDisabledAlert() -> Alert {
struct GroupChatInfoView_Previews: PreviewProvider { struct GroupChatInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupChatInfoView(chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []), groupInfo: GroupInfo.sampleData) GroupChatInfoView(
chat: Chat(chatInfo: ChatInfo.sampleData.group, chatItems: []),
groupInfo: Binding.constant(GroupInfo.sampleData)
)
} }
} }
@@ -13,6 +13,9 @@ struct GroupLinkView: View {
var groupId: Int64 var groupId: Int64
@Binding var groupLink: String? @Binding var groupLink: String?
@Binding var groupLinkMemberRole: GroupMemberRole @Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false
var creatingGroup: Bool = false
var linkCreatedCb: (() -> Void)? = nil
@State private var creatingLink = false @State private var creatingLink = false
@State private var alert: GroupLinkAlert? @State private var alert: GroupLinkAlert?
@@ -29,10 +32,35 @@ struct GroupLinkView: View {
} }
var body: some View { var body: some View {
if creatingGroup {
NavigationView {
groupLinkView()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() }
}
}
}
} else {
groupLinkView()
}
}
private func groupLinkView() -> some View {
List { List {
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.") Group {
.listRowBackground(Color.clear) if showTitle {
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) Text("Group link")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
}
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section { Section {
if let groupLink = groupLink { if let groupLink = groupLink {
Picker("Initial role", selection: $groupLinkMemberRole) { Picker("Initial role", selection: $groupLinkMemberRole) {
@@ -41,15 +69,17 @@ struct GroupLinkView: View {
} }
} }
.frame(height: 36) .frame(height: 36)
QRCode(uri: groupLink) SimpleXLinkQRCode(uri: groupLink)
Button { Button {
showShareSheet(items: [groupLink]) showShareSheet(items: [simplexChatLink(groupLink)])
} label: { } label: {
Label("Share link", systemImage: "square.and.arrow.up") Label("Share link", systemImage: "square.and.arrow.up")
} }
Button(role: .destructive) { alert = .deleteLink } label: { if !creatingGroup {
Label("Delete link", systemImage: "trash") Button(role: .destructive) { alert = .deleteLink } label: {
Label("Delete link", systemImage: "trash")
}
} }
} else { } else {
Button(action: createGroupLink) { Button(action: createGroupLink) {
@@ -12,38 +12,40 @@ import SimpleXChat
struct GroupMemberInfoView: View { struct GroupMemberInfoView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
var groupInfo: GroupInfo @State var groupInfo: GroupInfo
@State var member: GroupMember @ObservedObject var groupMember: GMember
var navigation: Bool = false var navigation: Bool = false
@State private var connectionStats: ConnectionStats? = nil @State private var connectionStats: ConnectionStats? = nil
@State private var connectionCode: String? = nil @State private var connectionCode: String? = nil
@State private var newRole: GroupMemberRole = .member @State private var newRole: GroupMemberRole = .member
@State private var alert: GroupMemberInfoViewAlert? @State private var alert: GroupMemberInfoViewAlert?
@State private var connectToMemberDialog: Bool = false @State private var sheet: PlanAndConnectActionSheet?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false @AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@State private var justOpened = true @State private var justOpened = true
@State private var progressIndicator = false @State private var progressIndicator = false
enum GroupMemberInfoViewAlert: Identifiable { enum GroupMemberInfoViewAlert: Identifiable {
case blockMemberAlert(mem: GroupMember)
case unblockMemberAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember) case removeMemberAlert(mem: GroupMember)
case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole) case changeMemberRoleAlert(mem: GroupMember, role: GroupMemberRole)
case switchAddressAlert case switchAddressAlert
case abortSwitchAddressAlert case abortSwitchAddressAlert
case syncConnectionForceAlert case syncConnectionForceAlert
case connRequestSentAlert(type: ConnReqType) case planAndConnectAlert(alert: PlanAndConnectAlert)
case error(title: LocalizedStringKey, error: LocalizedStringKey) case error(title: LocalizedStringKey, error: LocalizedStringKey)
case other(alert: Alert)
var id: String { var id: String {
switch self { switch self {
case .removeMemberAlert: return "removeMemberAlert" case let .blockMemberAlert(mem): return "blockMemberAlert \(mem.groupMemberId)"
case let .changeMemberRoleAlert(_, role): return "changeMemberRoleAlert \(role.rawValue)" case let .unblockMemberAlert(mem): return "unblockMemberAlert \(mem.groupMemberId)"
case let .removeMemberAlert(mem): return "removeMemberAlert \(mem.groupMemberId)"
case let .changeMemberRoleAlert(mem, role): return "changeMemberRoleAlert \(mem.groupMemberId) \(role.rawValue)"
case .switchAddressAlert: return "switchAddressAlert" case .switchAddressAlert: return "switchAddressAlert"
case .abortSwitchAddressAlert: return "abortSwitchAddressAlert" case .abortSwitchAddressAlert: return "abortSwitchAddressAlert"
case .syncConnectionForceAlert: return "syncConnectionForceAlert" case .syncConnectionForceAlert: return "syncConnectionForceAlert"
case .connRequestSentAlert: return "connRequestSentAlert" case let .planAndConnectAlert(alert): return "planAndConnectAlert \(alert.id)"
case let .error(title, _): return "error \(title)" case let .error(title, _): return "error \(title)"
case let .other(alert): return "other \(alert)"
} }
} }
} }
@@ -68,6 +70,7 @@ struct GroupMemberInfoView: View {
private func groupMemberInfoView() -> some View { private func groupMemberInfoView() -> some View {
ZStack { ZStack {
VStack { VStack {
let member = groupMember.wrapped
List { List {
groupMemberInfoHeader(member) groupMemberInfoHeader(member)
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
@@ -96,9 +99,9 @@ struct GroupMemberInfoView: View {
if let contactLink = member.contactLink { if let contactLink = member.contactLink {
Section { Section {
QRCode(uri: contactLink) SimpleXLinkQRCode(uri: contactLink)
Button { Button {
showShareSheet(items: [contactLink]) showShareSheet(items: [simplexChatLink(contactLink)])
} label: { } label: {
Label("Share address", systemImage: "square.and.arrow.up") Label("Share address", systemImage: "square.and.arrow.up")
} }
@@ -161,9 +164,14 @@ struct GroupMemberInfoView: View {
} }
} }
if member.canBeRemoved(groupInfo: groupInfo) { Section {
Section { if member.memberSettings.showMessages {
removeMemberButton(member) blockMemberButton(member)
} else {
unblockMemberButton(member)
}
if member.canBeRemoved(groupInfo: groupInfo) {
removeMemberButton(member)
} }
} }
@@ -184,7 +192,7 @@ struct GroupMemberInfoView: View {
do { do {
let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId) let (_, stats) = try apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil) let (mem, code) = member.memberActive ? try apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
member = mem _ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats connectionStats = stats
connectionCode = code connectionCode = code
} catch let error { } catch let error {
@@ -192,25 +200,30 @@ struct GroupMemberInfoView: View {
} }
justOpened = false justOpened = false
} }
.onChange(of: newRole) { _ in .onChange(of: newRole) { newRole in
if newRole != member.memberRole { if newRole != member.memberRole {
alert = .changeMemberRoleAlert(mem: member, role: newRole) alert = .changeMemberRoleAlert(mem: member, role: newRole)
} }
} }
.onChange(of: member.memberRole) { role in
newRole = role
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.alert(item: $alert) { alertItem in .alert(item: $alert) { alertItem in
switch(alertItem) { switch(alertItem) {
case let .blockMemberAlert(mem): return blockMemberAlert(groupInfo, mem)
case let .unblockMemberAlert(mem): return unblockMemberAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem) case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem) case let .changeMemberRoleAlert(mem, _): return changeMemberRoleAlert(mem)
case .switchAddressAlert: return switchAddressAlert(switchMemberAddress) case .switchAddressAlert: return switchAddressAlert(switchMemberAddress)
case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress) case .abortSwitchAddressAlert: return abortSwitchAddressAlert(abortSwitchMemberAddress)
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) }) case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .connRequestSentAlert(type): return connReqSentAlert(type) case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .error(title, error): return Alert(title: Text(title), message: Text(error)) case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .other(alert): return alert
} }
} }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
if progressIndicator { if progressIndicator {
ProgressView().scaleEffect(2) ProgressView().scaleEffect(2)
@@ -220,25 +233,16 @@ struct GroupMemberInfoView: View {
func connectViaAddressButton(_ contactLink: String) -> some View { func connectViaAddressButton(_ contactLink: String) -> some View {
Button { Button {
connectToMemberDialog = true planAndConnect(
contactLink,
showAlert: { alert = .planAndConnectAlert(alert: $0) },
showActionSheet: { sheet = $0 },
dismiss: true,
incognito: nil
)
} label: { } label: {
Label("Connect", systemImage: "link") Label("Connect", systemImage: "link")
} }
.confirmationDialog("Connect directly", isPresented: $connectToMemberDialog, titleVisibility: .visible) {
Button("Use current profile") { connectViaAddress(incognito: false, contactLink: contactLink) }
Button("Use new incognito profile") { connectViaAddress(incognito: true, contactLink: contactLink) }
}
}
func connectViaAddress(incognito: Bool, contactLink: String) {
Task {
let (connReqType, connectAlert) = await apiConnect_(incognito: incognito, connReq: contactLink)
if let connReqType = connReqType {
alert = .connRequestSentAlert(type: connReqType)
} else if let connectAlert = connectAlert {
alert = .other(alert: connectAlert)
}
}
} }
func knownDirectChatButton(_ chat: Chat) -> some View { func knownDirectChatButton(_ chat: Chat) -> some View {
@@ -274,7 +278,7 @@ struct GroupMemberInfoView: View {
progressIndicator = true progressIndicator = true
Task { Task {
do { do {
let memberContact = try await apiCreateMemberContact(groupInfo.apiId, member.groupMemberId) let memberContact = try await apiCreateMemberContact(groupInfo.apiId, groupMember.groupMemberId)
await MainActor.run { await MainActor.run {
progressIndicator = false progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
@@ -332,20 +336,20 @@ struct GroupMemberInfoView: View {
} }
private func verifyCodeButton(_ code: String) -> some View { private func verifyCodeButton(_ code: String) -> some View {
NavigationLink { let member = groupMember.wrapped
return NavigationLink {
VerifyCodeView( VerifyCodeView(
displayName: member.displayName, displayName: member.displayName,
connectionCode: code, connectionCode: code,
connectionVerified: member.verified, connectionVerified: member.verified,
verify: { code in verify: { code in
var member = groupMember.wrapped
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) { if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
let (verified, existingCode) = r let (verified, existingCode) = r
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
connectionCode = existingCode connectionCode = existingCode
member.activeConn?.connectionCode = connCode member.activeConn?.connectionCode = connCode
if let i = chatModel.groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) { _ = chatModel.upsertGroupMember(groupInfo, member)
chatModel.groupMembers[i].activeConn?.connectionCode = connCode
}
return r return r
} }
return nil return nil
@@ -379,12 +383,29 @@ struct GroupMemberInfoView: View {
} }
} }
private func blockMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) {
alert = .blockMemberAlert(mem: mem)
} label: {
Label("Block member", systemImage: "hand.raised")
.foregroundColor(.red)
}
}
private func unblockMemberButton(_ mem: GroupMember) -> some View {
Button {
alert = .unblockMemberAlert(mem: mem)
} label: {
Label("Unblock member", systemImage: "hand.raised.slash")
}
}
private func removeMemberButton(_ mem: GroupMember) -> some View { private func removeMemberButton(_ mem: GroupMember) -> some View {
Button(role: .destructive) { Button(role: .destructive) {
alert = .removeMemberAlert(mem: mem) alert = .removeMemberAlert(mem: mem)
} label: { } label: {
Label("Remove member", systemImage: "trash") Label("Remove member", systemImage: "trash")
.foregroundColor(Color.red) .foregroundColor(.red)
} }
} }
@@ -420,7 +441,6 @@ struct GroupMemberInfoView: View {
do { do {
let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole) let updatedMember = try await apiMemberRole(groupInfo.groupId, mem.groupMemberId, newRole)
await MainActor.run { await MainActor.run {
member = updatedMember
_ = chatModel.upsertGroupMember(groupInfo, updatedMember) _ = chatModel.upsertGroupMember(groupInfo, updatedMember)
} }
@@ -441,10 +461,10 @@ struct GroupMemberInfoView: View {
private func switchMemberAddress() { private func switchMemberAddress() {
Task { Task {
do { do {
let stats = try apiSwitchGroupMember(groupInfo.apiId, member.groupMemberId) let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
connectionStats = stats connectionStats = stats
await MainActor.run { await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
dismiss() dismiss()
} }
} catch let error { } catch let error {
@@ -460,10 +480,10 @@ struct GroupMemberInfoView: View {
private func abortSwitchMemberAddress() { private func abortSwitchMemberAddress() {
Task { Task {
do { do {
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, member.groupMemberId) let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
connectionStats = stats connectionStats = stats
await MainActor.run { await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, member, stats) chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
} }
} catch let error { } catch let error {
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))") logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
@@ -478,7 +498,7 @@ struct GroupMemberInfoView: View {
private func syncMemberConnection(force: Bool) { private func syncMemberConnection(force: Bool) {
Task { Task {
do { do {
let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, member.groupMemberId, force) let (mem, stats) = try apiSyncGroupMemberRatchet(groupInfo.apiId, groupMember.groupMemberId, force)
connectionStats = stats connectionStats = stats
await MainActor.run { await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats) chatModel.updateGroupMemberConnectionStats(groupInfo, mem, stats)
@@ -495,11 +515,54 @@ struct GroupMemberInfoView: View {
} }
} }
func blockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Block member?"),
message: Text("All new messages from \(mem.chatViewName) will be hidden!"),
primaryButton: .destructive(Text("Block")) {
toggleShowMemberMessages(gInfo, mem, false)
},
secondaryButton: .cancel()
)
}
func unblockMemberAlert(_ gInfo: GroupInfo, _ mem: GroupMember) -> Alert {
Alert(
title: Text("Unblock member?"),
message: Text("Messages from \(mem.chatViewName) will be shown!"),
primaryButton: .default(Text("Unblock")) {
toggleShowMemberMessages(gInfo, mem, true)
},
secondaryButton: .cancel()
)
}
func toggleShowMemberMessages(_ gInfo: GroupInfo, _ member: GroupMember, _ showMessages: Bool) {
var memberSettings = member.memberSettings
memberSettings.showMessages = showMessages
updateMemberSettings(gInfo, member, memberSettings)
}
func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSettings: GroupMemberSettings) {
Task {
do {
try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings)
await MainActor.run {
var mem = member
mem.memberSettings = memberSettings
_ = ChatModel.shared.upsertGroupMember(gInfo, mem)
}
} catch let error {
logger.error("apiSetMemberSettings error \(responseError(error))")
}
}
}
struct GroupMemberInfoView_Previews: PreviewProvider { struct GroupMemberInfoView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
GroupMemberInfoView( GroupMemberInfoView(
groupInfo: GroupInfo.sampleData, groupInfo: GroupInfo.sampleData,
member: GroupMember.sampleData groupMember: GMember.sampleData
) )
} }
} }
@@ -27,8 +27,7 @@ struct GroupPreferencesView: View {
featureSection(.directMessages, $preferences.directMessages.enable) featureSection(.directMessages, $preferences.directMessages.enable)
featureSection(.reactions, $preferences.reactions.enable) featureSection(.reactions, $preferences.reactions.enable)
featureSection(.voice, $preferences.voice.enable) featureSection(.voice, $preferences.voice.enable)
// TODO uncomment in 5.3 featureSection(.files, $preferences.files.enable)
// featureSection(.files, $preferences.files.enable)
if groupInfo.canEdit { if groupInfo.canEdit {
Section { Section {
@@ -9,6 +9,18 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
enum GroupProfileAlert: Identifiable {
case saveError(err: String)
case invalidName(validName: String)
var id: String {
switch self {
case let .saveError(err): return "saveError \(err)"
case let .invalidName(validName): return "invalidName \(validName)"
}
}
}
struct GroupProfileView: View { struct GroupProfileView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@@ -18,8 +30,7 @@ struct GroupProfileView: View {
@State private var showImagePicker = false @State private var showImagePicker = false
@State private var showTakePhoto = false @State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil @State private var chosenImage: UIImage? = nil
@State private var showSaveErrorAlert = false @State private var alert: GroupProfileAlert?
@State private var saveGroupError: String? = nil
@FocusState private var focusDisplayName @FocusState private var focusDisplayName
var body: some View { var body: some View {
@@ -47,20 +58,29 @@ struct GroupProfileView: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
VStack(alignment: .leading) { VStack(alignment: .leading) {
ZStack(alignment: .leading) { ZStack(alignment: .topLeading) {
if !validDisplayName(groupProfile.displayName) { if !validNewProfileName() {
Image(systemName: "exclamationmark.circle") Button {
.foregroundColor(.red) alert = .invalidName(validName: mkValidName(groupProfile.displayName))
.padding(.bottom, 10) } label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
} }
profileNameTextEdit("Group display name", $groupProfile.displayName) profileNameTextEdit("Group display name", $groupProfile.displayName)
.focused($focusDisplayName) .focused($focusDisplayName)
} }
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName) .padding(.bottom)
let fullName = groupInfo.groupProfile.fullName
if fullName != "" && fullName != groupProfile.displayName {
profileNameTextEdit("Group full name (optional)", $groupProfile.fullName)
.padding(.bottom)
}
HStack(spacing: 20) { HStack(spacing: 20) {
Button("Cancel") { dismiss() } Button("Cancel") { dismiss() }
Button("Save group profile") { saveProfile() } Button("Save group profile") { saveProfile() }
.disabled(groupProfile.displayName == "" || !validDisplayName(groupProfile.displayName)) .disabled(!canUpdateProfile())
} }
} }
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
@@ -99,27 +119,39 @@ struct GroupProfileView: View {
focusDisplayName = true focusDisplayName = true
} }
} }
.alert(isPresented: $showSaveErrorAlert) { .alert(item: $alert) { a in
Alert( switch a {
title: Text("Error saving group profile"), case let .saveError(err):
message: Text("\(saveGroupError ?? "Unexpected error")") return Alert(
) title: Text("Error saving group profile"),
message: Text(err)
)
case let .invalidName(name):
return createInvalidNameAlert(name, $groupProfile.displayName)
}
} }
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { hideKeyboard() } .onTapGesture { hideKeyboard() }
} }
private func canUpdateProfile() -> Bool {
groupProfile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName()
}
private func validNewProfileName() -> Bool {
groupProfile.displayName == groupInfo.groupProfile.displayName
|| validDisplayName(groupProfile.displayName.trimmingCharacters(in: .whitespaces))
}
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View { func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
TextField(label, text: name) TextField(label, text: name)
.textInputAutocapitalization(.never) .padding(.leading, 32)
.disableAutocorrection(true)
.padding(.bottom)
.padding(.leading, 28)
} }
func saveProfile() { func saveProfile() {
Task { Task {
do { do {
groupProfile.displayName = groupProfile.displayName.trimmingCharacters(in: .whitespaces)
let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile) let gInfo = try await apiUpdateGroup(groupInfo.groupId, groupProfile)
await MainActor.run { await MainActor.run {
groupInfo = gInfo groupInfo = gInfo
@@ -128,8 +160,7 @@ struct GroupProfileView: View {
} }
} catch let error { } catch let error {
let err = responseError(error) let err = responseError(error)
saveGroupError = err alert = .saveError(err: err)
showSaveErrorAlert = true
logger.error("GroupProfile apiUpdateGroup error: \(err)") logger.error("GroupProfile apiUpdateGroup error: \(err)")
} }
} }
@@ -32,19 +32,33 @@ struct ChatListNavLink: View {
@State private var showJoinGroupDialog = false @State private var showJoinGroupDialog = false
@State private var showContactConnectionInfo = false @State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false @State private var showInvalidJSON = false
@State private var showDeleteContactActionSheet = false
@State private var inProgress = false
@State private var progressByTimeout = false
var body: some View { var body: some View {
switch chat.chatInfo { Group {
case let .direct(contact): switch chat.chatInfo {
contactNavLink(contact) case let .direct(contact):
case let .group(groupInfo): contactNavLink(contact)
groupNavLink(groupInfo) case let .group(groupInfo):
case let .contactRequest(cReq): groupNavLink(groupInfo)
contactRequestNavLink(cReq) case let .contactRequest(cReq):
case let .contactConnection(cConn): contactRequestNavLink(cReq)
contactConnectionNavLink(cConn) case let .contactConnection(cConn):
case let .invalidJSON(json): contactConnectionNavLink(cConn)
invalidJSONPreview(json) case let .invalidJSON(json):
invalidJSONPreview(json)
}
}
.onChange(of: inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
} }
} }
@@ -52,7 +66,7 @@ struct ChatListNavLink: View {
NavLinkPlain( NavLinkPlain(
tag: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat) } label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
) )
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
@@ -64,23 +78,43 @@ struct ChatListNavLink: View {
clearChatButton() clearChatButton()
} }
Button { Button {
AlertManager.shared.showAlert( if contact.ready || !contact.active {
contact.ready showDeleteContactActionSheet = true
? deleteContactAlert(chat.chatInfo) } else {
: deletePendingContactAlert(chat, contact) AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
) }
} label: { } label: {
Label("Delete", systemImage: "trash") Label("Delete", systemImage: "trash")
} }
.tint(.red) .tint(.red)
} }
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete and notify contact")) { Task { await deleteChat(chat, notify: true) } },
.destructive(Text("Delete")) { Task { await deleteChat(chat, notify: false) } },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
.destructive(Text("Delete")) { Task { await deleteChat(chat) } },
.cancel()
]
)
}
}
} }
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View { @ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: case .memInvited:
ChatPreviewView(chat: chat) ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton() joinGroupButton()
@@ -91,12 +125,16 @@ struct ChatListNavLink: View {
.onTapGesture { showJoinGroupDialog = true } .onTapGesture { showJoinGroupDialog = true }
.confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) { .confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) {
Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") { Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") {
joinGroup(groupInfo.groupId) inProgress = true
joinGroup(groupInfo.groupId) {
await MainActor.run { inProgress = false }
}
} }
Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } } Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } }
} }
.disabled(inProgress)
case .memAccepted: case .memAccepted:
ChatPreviewView(chat: chat) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
@@ -113,7 +151,7 @@ struct ChatListNavLink: View {
NavLinkPlain( NavLinkPlain(
tag: chat.chatInfo.id, tag: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready disabled: !groupInfo.ready
) )
.frame(height: rowHeights[dynamicTypeSize]) .frame(height: rowHeights[dynamicTypeSize])
@@ -138,7 +176,10 @@ struct ChatListNavLink: View {
private func joinGroupButton() -> some View { private func joinGroupButton() -> some View {
Button { Button {
joinGroup(chat.chatInfo.apiId) inProgress = true
joinGroup(chat.chatInfo.apiId) {
await MainActor.run { inProgress = false }
}
} label: { } label: {
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward") Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
} }
@@ -269,17 +310,6 @@ struct ChatListNavLink: View {
} }
} }
private func deleteContactAlert(_ chatInfo: ChatInfo) -> Alert {
Alert(
title: Text("Delete contact?"),
message: Text("Contact and all messages will be deleted - this cannot be undone!"),
primaryButton: .destructive(Text("Delete")) {
Task { await deleteChat(chat) }
},
secondaryButton: .cancel()
)
}
private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert { private func deleteGroupAlert(_ groupInfo: GroupInfo) -> Alert {
Alert( Alert(
title: Text("Delete group?"), title: Text("Delete group?"),
@@ -409,7 +439,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
) )
} }
func joinGroup(_ groupId: Int64) { func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
Task { Task {
logger.debug("joinGroup") logger.debug("joinGroup")
do { do {
@@ -424,7 +454,9 @@ func joinGroup(_ groupId: Int64) {
AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.") AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
await deleteGroup() await deleteGroup()
} }
await onComplete()
} catch let error { } catch let error {
await onComplete()
let a = getErrorAlert(error, "Error joining group") let a = getErrorAlert(error, "Error joining group")
AlertManager.shared.showAlertMsg(title: a.title, message: a.message) AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
} }
@@ -29,7 +29,7 @@ struct ChatListView: View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
NavStackCompat( NavStackCompat(
isActive: Binding( isActive: Binding(
get: { ChatModel.shared.chatId != nil }, get: { chatModel.chatId != nil },
set: { _ in } set: { _ in }
), ),
destination: chatView destination: chatView
@@ -12,6 +12,7 @@ import SimpleXChat
struct ChatPreviewView: View { struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0) var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@@ -57,19 +58,26 @@ struct ChatPreviewView: View {
} }
@ViewBuilder private func chatPreviewImageOverlayIcon() -> some View { @ViewBuilder private func chatPreviewImageOverlayIcon() -> some View {
if case let .group(groupInfo) = chat.chatInfo { switch chat.chatInfo {
case let .direct(contact):
if !contact.active {
inactiveIcon()
} else {
EmptyView()
}
case let .group(groupInfo):
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memLeft: groupInactiveIcon() case .memLeft: inactiveIcon()
case .memRemoved: groupInactiveIcon() case .memRemoved: inactiveIcon()
case .memGroupDeleted: groupInactiveIcon() case .memGroupDeleted: inactiveIcon()
default: EmptyView() default: EmptyView()
} }
} else { default:
EmptyView() EmptyView()
} }
} }
@ViewBuilder private func groupInactiveIcon() -> some View { @ViewBuilder private func inactiveIcon() -> some View {
Image(systemName: "multiply.circle.fill") Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary.opacity(0.65)) .foregroundColor(.secondary.opacity(0.65))
.background(Circle().foregroundColor(Color(uiColor: .systemBackground))) .background(Circle().foregroundColor(Color(uiColor: .systemBackground)))
@@ -80,7 +88,6 @@ struct ChatPreviewView: View {
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
previewTitle(contact.verified == true ? verifiedIcon + t : t) previewTitle(contact.verified == true ? verifiedIcon + t : t)
.foregroundColor(chat.chatInfo.ready ? .primary : .secondary)
case let .group(groupInfo): case let .group(groupInfo):
let v = previewTitle(t) let v = previewTitle(t)
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
@@ -105,14 +112,17 @@ struct ChatPreviewView: View {
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View { private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
text let t = text
.lineLimit(2) .lineLimit(2)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, 8) .padding(.leading, 8)
.padding(.trailing, 36) .padding(.trailing, 36)
.privacySensitive(!showChatPreviews && !draft) if !showChatPreviews && !draft {
.redacted(reason: .privacy) t.privacySensitive(true).redacted(reason: .privacy)
} else {
t
}
let s = chat.chatStats let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat { if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount) unreadCountText(s.unreadCount)
@@ -183,7 +193,7 @@ struct ChatPreviewView: View {
if !contact.ready { if !contact.ready {
if contact.nextSendGrpInv { if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message") chatPreviewInfoText("send direct message")
} else { } else if contact.active {
chatPreviewInfoText("connecting…") chatPreviewInfoText("connecting…")
} }
} }
@@ -228,16 +238,26 @@ struct ChatPreviewView: View {
@ViewBuilder private func chatStatusImage() -> some View { @ViewBuilder private func chatStatusImage() -> some View {
switch chat.chatInfo { switch chat.chatInfo {
case let .direct(contact): case let .direct(contact):
switch (chatModel.contactNetworkStatus(contact)) { if contact.active {
case .connected: incognitoIcon(chat.chatInfo.incognito) switch (chatModel.contactNetworkStatus(contact)) {
case .error: case .connected: incognitoIcon(chat.chatInfo.incognito)
Image(systemName: "exclamationmark.circle") case .error:
.resizable() Image(systemName: "exclamationmark.circle")
.scaledToFit() .resizable()
.frame(width: 17, height: 17) .scaledToFit()
.foregroundColor(.secondary) .frame(width: 17, height: 17)
default: .foregroundColor(.secondary)
default:
ProgressView()
}
} else {
incognitoIcon(chat.chatInfo.incognito)
}
case .group:
if progressByTimeout {
ProgressView() ProgressView()
} else {
incognitoIcon(chat.chatInfo.incognito)
} }
default: default:
incognitoIcon(chat.chatInfo.incognito) incognitoIcon(chat.chatInfo.incognito)
@@ -267,30 +287,30 @@ struct ChatPreviewView_Previews: PreviewProvider {
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [] chatItems: []
)) ), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
)) ), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
)) ), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))] chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
)) ), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct, chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))], chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0) chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
)) ), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat( ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.group, chatInfo: ChatInfo.sampleData.group,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. 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.")], chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. 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.")],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0) chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
)) ), progressByTimeout: Binding.constant(false))
} }
.previewLayout(.fixed(width: 360, height: 78)) .previewLayout(.fixed(width: 360, height: 78))
} }
@@ -61,7 +61,7 @@ struct ContactConnectionInfo: View {
if contactConnection.initiated, if contactConnection.initiated,
let connReqInv = contactConnection.connReqInv { let connReqInv = contactConnection.connReqInv {
QRCode(uri: connReqInv) SimpleXLinkQRCode(uri: simplexChatLink(connReqInv))
incognitoEnabled() incognitoEnabled()
shareLinkButton(connReqInv) shareLinkButton(connReqInv)
oneTimeLinkLearnMoreButton() oneTimeLinkLearnMoreButton()
@@ -119,7 +119,7 @@ struct ContactConnectionInfo: View {
if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) { if let conn = try await apiSetConnectionAlias(connId: contactConnection.pccConnId, localAlias: localAlias) {
await MainActor.run { await MainActor.run {
contactConnection = conn contactConnection = conn
ChatModel.shared.updateContactConnection(conn) m.updateContactConnection(conn)
dismiss() dismiss()
} }
} }
@@ -52,7 +52,6 @@ struct VideoPlayerView: UIViewRepresentable {
var timeObserver: Any? = nil var timeObserver: Any? = nil
deinit { deinit {
print("deinit coordinator of VideoPlayer")
if let timeObserver = timeObserver { if let timeObserver = timeObserver {
NotificationCenter.default.removeObserver(timeObserver) NotificationCenter.default.removeObserver(timeObserver)
} }
@@ -21,7 +21,7 @@ struct AddContactView: View {
List { List {
Section { Section {
if connReqInvitation != "" { if connReqInvitation != "" {
QRCode(uri: connReqInvitation) SimpleXLinkQRCode(uri: connReqInvitation)
} else { } else {
ProgressView() ProgressView()
.progressViewStyle(.circular) .progressViewStyle(.circular)
@@ -48,7 +48,7 @@ struct AddContactView: View {
let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) { let conn = try await apiSetConnectionIncognito(connId: contactConn.pccConnId, incognito: incognito) {
await MainActor.run { await MainActor.run {
contactConnection = conn contactConnection = conn
ChatModel.shared.updateContactConnection(conn) chatModel.updateContactConnection(conn)
} }
} }
} catch { } catch {
@@ -99,7 +99,7 @@ func sharedProfileInfo(_ incognito: Bool) -> Text {
func shareLinkButton(_ connReqInvitation: String) -> some View { func shareLinkButton(_ connReqInvitation: String) -> some View {
Button { Button {
showShareSheet(items: [connReqInvitation]) showShareSheet(items: [simplexChatLink(connReqInvitation)])
} label: { } label: {
settingsRow("square.and.arrow.up") { settingsRow("square.and.arrow.up") {
Text("Share 1-time link") Text("Share 1-time link")
+108 -77
View File
@@ -12,27 +12,45 @@ import SimpleXChat
struct AddGroupView: View { struct AddGroupView: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var chat: Chat? @State private var chat: Chat?
@State private var groupInfo: GroupInfo? @State private var groupInfo: GroupInfo?
@State private var profile = GroupProfile(displayName: "", fullName: "") @State private var profile = GroupProfile(displayName: "", fullName: "")
@FocusState private var focusDisplayName @FocusState private var focusDisplayName
@FocusState private var focusFullName
@State private var showChooseSource = false @State private var showChooseSource = false
@State private var showImagePicker = false @State private var showImagePicker = false
@State private var showTakePhoto = false @State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil @State private var chosenImage: UIImage? = nil
@State private var showInvalidNameAlert = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
var body: some View { var body: some View {
if let chat = chat, let groupInfo = groupInfo { if let chat = chat, let groupInfo = groupInfo {
AddGroupMembersViewCommon( if !groupInfo.membership.memberIncognito {
chat: chat, AddGroupMembersViewCommon(
groupInfo: groupInfo, chat: chat,
creatingGroup: true, groupInfo: groupInfo,
showFooterCounter: false creatingGroup: true,
) { _ in showFooterCounter: false
dismiss() ) { _ in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { dismiss()
m.chatId = groupInfo.id DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
m.chatId = groupInfo.id
}
}
} else {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: true
) {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
m.chatId = groupInfo.id
}
} }
} }
} else { } else {
@@ -41,79 +59,62 @@ struct AddGroupView: View {
} }
func createGroupView() -> some View { func createGroupView() -> some View {
VStack(alignment: .leading) { List {
Text("Create secret group") Group {
.font(.largeTitle) Text("Create secret group")
.padding(.vertical, 4) .font(.largeTitle)
Text("The group is fully decentralized it is visible only to the members.") .bold()
.padding(.bottom, 4) .fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 24)
.onTapGesture(perform: hideKeyboard)
HStack { ZStack(alignment: .center) {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote) ZStack(alignment: .topTrailing) {
Spacer().frame(width: 8) ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground))
Text("Your chat profile will be sent to group members").font(.footnote) .aspectRatio(1, contentMode: .fit)
} .frame(maxWidth: 128, maxHeight: 128)
.padding(.bottom) if profile.image != nil {
Button {
ZStack(alignment: .center) { profile.image = nil
ZStack(alignment: .topTrailing) { } label: {
profileImageView(profile.image) Image(systemName: "multiply")
if profile.image != nil { .resizable()
Button { .aspectRatio(contentMode: .fit)
profile.image = nil .frame(width: 12)
} label: { }
Image(systemName: "multiply")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12)
} }
} }
editImageButton { showChooseSource = true }
.buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable
} }
.frame(maxWidth: .infinity, alignment: .center)
editImageButton { showChooseSource = true }
} }
.frame(maxWidth: .infinity, alignment: .center) .listRowBackground(Color.clear)
.padding(.bottom, 4) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
ZStack(alignment: .topLeading) { Section {
if !validDisplayName(profile.displayName) { groupNameTextField()
Image(systemName: "exclamationmark.circle") Button(action: createGroup) {
.foregroundColor(.red) settingsRow("checkmark", color: .accentColor) { Text("Create group") }
.padding(.top, 4)
} }
textField("Group display name", text: $profile.displayName) .disabled(!canCreateProfile())
.focused($focusDisplayName) IncognitoToggle(incognitoEnabled: $incognitoDefault)
.submitLabel(.next) } footer: {
.onSubmit { VStack(alignment: .leading, spacing: 4) {
if canCreateProfile() { focusFullName = true } sharedGroupProfileInfo(incognitoDefault)
else { focusDisplayName = true } Text("Fully decentralized visible only to members.")
}
}
textField("Group full name (optional)", text: $profile.fullName)
.focused($focusFullName)
.submitLabel(.go)
.onSubmit {
if canCreateProfile() { createGroup() }
else { focusFullName = true }
} }
.frame(maxWidth: .infinity, alignment: .leading)
Spacer() .onTapGesture(perform: hideKeyboard)
Button {
createGroup()
} label: {
Text("Create")
Image(systemName: "greaterthan")
} }
.disabled(!canCreateProfile())
.frame(maxWidth: .infinity, alignment: .trailing)
} }
.onAppear() { .onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) { DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true focusDisplayName = true
} }
} }
.padding()
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) { .confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") { Button("Take picture") {
showTakePhoto = true showTakePhoto = true
@@ -133,6 +134,9 @@ struct AddGroupView: View {
didSelectItem in showImagePicker = false didSelectItem in showImagePicker = false
} }
} }
.alert(isPresented: $showInvalidNameAlert) {
createInvalidNameAlert(mkValidName(profile.displayName), $profile.displayName)
}
.onChange(of: chosenImage) { image in .onChange(of: chosenImage) { image in
if let image = image { if let image = image {
profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500) profile.image = resizeImageToStrSize(cropToSquare(image), maxDataSize: 12500)
@@ -140,26 +144,52 @@ struct AddGroupView: View {
profile.image = nil profile.image = nil
} }
} }
.contentShape(Rectangle()) }
.onTapGesture { hideKeyboard() }
func groupNameTextField() -> some View {
ZStack(alignment: .leading) {
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
if name != mkValidName(name) {
Button {
showInvalidNameAlert = true
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "pencil").foregroundColor(.secondary)
}
textField("Enter group name…", text: $profile.displayName)
.focused($focusDisplayName)
.submitLabel(.continue)
.onSubmit {
if canCreateProfile() { createGroup() }
}
}
} }
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View { func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
TextField(placeholder, text: text) TextField(placeholder, text: text)
.textInputAutocapitalization(.never) .padding(.leading, 36)
.disableAutocorrection(true) }
.padding(.leading, 28)
.padding(.bottom) func sharedGroupProfileInfo(_ incognito: Bool) -> Text {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
incognito
? "A new random profile will be shared."
: "Your profile **\(name)** will be shared."
)
} }
func createGroup() { func createGroup() {
hideKeyboard() hideKeyboard()
do { do {
let gInfo = try apiNewGroup(profile) profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
Task { Task {
let groupMembers = await apiListMembers(gInfo.groupId) let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run { await MainActor.run {
ChatModel.shared.groupMembers = groupMembers m.groupMembers = groupMembers.map { GMember.init($0) }
} }
} }
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: []) let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
@@ -180,7 +210,8 @@ struct AddGroupView: View {
} }
func canCreateProfile() -> Bool { func canCreateProfile() -> Bool {
profile.displayName != "" && validDisplayName(profile.displayName) let name = profile.displayName.trimmingCharacters(in: .whitespaces)
return name != "" && validDisplayName(name)
} }
} }
+344 -40
View File
@@ -58,65 +58,369 @@ struct NewChatButton: View {
} }
} }
enum ConnReqType: Equatable { enum PlanAndConnectAlert: Identifiable {
case contact case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case invitation case invitationLinkConnecting(connectionLink: String)
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?)
var id: String {
switch self {
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)"
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)"
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)"
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)"
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)"
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)"
}
}
} }
func connectViaLink(_ connectionLink: String, dismiss: DismissAction? = nil, incognito: Bool) { func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool) -> Alert {
Task { switch alert {
if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) { case let .ownInvitationLinkConfirmConnect(connectionLink, connectionPlan, incognito):
DispatchQueue.main.async { return Alert(
dismiss?() title: Text("Connect to yourself?"),
AlertManager.shared.showAlert(connReqSentAlert(connReqType)) message: Text("This is your own one-time link!"),
} primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case .invitationLinkConnecting:
return Alert(
title: Text("Already connecting!"),
message: Text("You are already connecting via this one-time link!")
)
case let .ownContactAddressConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Connect to yourself?"),
message: Text("This is your own SimpleX address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .contactAddressConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat connection request?"),
message: Text("You have already requested connection via this address!"),
primaryButton: .destructive(
Text(incognito ? "Connect incognito" : "Connect"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConfirmConnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Join group?"),
message: Text("You will connect to all group members."),
primaryButton: .default(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConnectingConfirmReconnect(connectionLink, connectionPlan, incognito):
return Alert(
title: Text("Repeat join request?"),
message: Text("You are already joining the group via this link!"),
primaryButton: .destructive(
Text(incognito ? "Join incognito" : "Join"),
action: { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) }
),
secondaryButton: .cancel()
)
case let .groupLinkConnecting(_, groupInfo):
if let groupInfo = groupInfo {
return Alert(
title: Text("Group already exists!"),
message: Text("You are already joining the group \(groupInfo.displayName).")
)
} else { } else {
DispatchQueue.main.async { return Alert(
dismiss?() title: Text("Already joining the group!"),
message: Text("You are already joining the group via this link.")
)
}
}
}
enum PlanAndConnectActionSheet: Identifiable {
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
var id: String {
switch self {
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)"
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)"
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)"
}
}
}
func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool) -> ActionSheet {
switch sheet {
case let .askCurrentOrIncognitoProfile(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.default(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.default(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, connectionPlan, title):
return ActionSheet(
title: Text(title),
buttons: [
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
case let .ownGroupLinkConfirmConnect(connectionLink, connectionPlan, incognito, groupInfo):
if let incognito = incognito {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text(incognito ? "Join incognito" : "Join with current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito) },
.cancel()
]
)
} else {
return ActionSheet(
title: Text("Join your group?\nThis is your link for group \(groupInfo.displayName)!"),
buttons: [
.default(Text("Open group")) { openKnownGroup(groupInfo, dismiss: dismiss, showAlreadyExistsAlert: nil) },
.destructive(Text("Use current profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: false) },
.destructive(Text("Use new incognito profile")) { connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: true) },
.cancel()
]
)
}
}
}
func planAndConnect(
_ connectionLink: String,
showAlert: @escaping (PlanAndConnectAlert) -> Void,
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
dismiss: Bool,
incognito: Bool?
) {
Task {
do {
let connectionPlan = try await apiConnectPlan(connReq: connectionLink)
switch connectionPlan {
case let .invitationLink(ilp):
switch ilp {
case .ok:
logger.debug("planAndConnect, .invitationLink, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
}
case .ownLink:
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
}
case let .connecting(contact_):
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
if let contact = contact_ {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
} else {
showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
}
case let .known(contact):
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
}
case let .contactAddress(cap):
switch cap {
case .ok:
logger.debug("planAndConnect, .contactAddress, .ok, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
}
case .ownLink:
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
}
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
}
case let .connectingProhibit(contact):
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
case let .known(contact):
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
}
case let .groupLink(glp):
switch glp {
case .ok:
if let incognito = incognito {
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
}
case let .ownLink(groupInfo):
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
case .connectingConfirmReconnect:
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito {
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
}
case let .connectingProhibit(groupInfo_):
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
case let .known(groupInfo):
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
openKnownGroup(groupInfo, dismiss: dismiss) { AlertManager.shared.showAlert(groupAlreadyExistsAlert(groupInfo)) }
}
}
} catch {
logger.debug("planAndConnect, plan error")
if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito)
} else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
} }
} }
} }
} }
struct CReqClientData: Decodable { private func connectViaLink(_ connectionLink: String, connectionPlan: ConnectionPlan?, dismiss: Bool, incognito: Bool) {
var type: String Task {
var groupLinkId: String? if let connReqType = await apiConnect(incognito: incognito, connReq: connectionLink) {
} let crt: ConnReqType
if let plan = connectionPlan {
func parseLinkQueryData(_ connectionLink: String) -> CReqClientData? { crt = planToConnReqType(plan)
if let hashIndex = connectionLink.firstIndex(of: "#"), } else {
let urlQuery = URL(string: String(connectionLink[connectionLink.index(after: hashIndex)...])), crt = connReqType
let components = URLComponents(url: urlQuery, resolvingAgainstBaseURL: false), }
let data = components.queryItems?.first(where: { $0.name == "data" })?.value, DispatchQueue.main.async {
let d = data.data(using: .utf8), if dismiss {
let crData = try? getJSONDecoder().decode(CReqClientData.self, from: d) { dismissAllSheets(animated: true) {
return crData AlertManager.shared.showAlert(connReqSentAlert(crt))
} else { }
return nil } else {
AlertManager.shared.showAlert(connReqSentAlert(crt))
}
}
} else {
if dismiss {
DispatchQueue.main.async {
dismissAllSheets(animated: true)
}
}
}
} }
} }
func checkCRDataGroup(_ crData: CReqClientData) -> Bool { func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
return crData.type == "group" && crData.groupLinkId != nil Task {
let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) {
DispatchQueue.main.async {
if dismiss {
dismissAllSheets(animated: true) {
m.chatId = c.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = c.id
showAlreadyExistsAlert?()
}
}
}
}
} }
func groupLinkAlert(_ connectionLink: String, incognito: Bool) -> Alert { func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
return Alert( Task {
title: Text("Connect via group link?"), let m = ChatModel.shared
message: Text("You will join a group this link refers to and connect to its group members."), if let g = m.getGroupChat(groupInfo.groupId) {
primaryButton: .default(Text(incognito ? "Connect incognito" : "Connect")) { DispatchQueue.main.async {
connectViaLink(connectionLink, incognito: incognito) if dismiss {
}, dismissAllSheets(animated: true) {
secondaryButton: .cancel() m.chatId = g.id
showAlreadyExistsAlert?()
}
} else {
m.chatId = g.id
showAlreadyExistsAlert?()
}
}
}
}
}
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
mkAlert(
title: "Contact already exists",
message: "You are already connecting to \(contact.displayName)."
) )
} }
func groupAlreadyExistsAlert(_ groupInfo: GroupInfo) -> Alert {
mkAlert(
title: "Group already exists",
message: "You are already in group \(groupInfo.displayName)."
)
}
enum ConnReqType: Equatable {
case invitation
case contact
case groupLink
var connReqSentText: LocalizedStringKey {
switch self {
case .invitation: return "You will be connected when your contact's device is online, please wait or check later!"
case .contact: return "You will be connected when your connection request is accepted, please wait or check later!"
case .groupLink: return "You will be connected when group link host's device is online, please wait or check later!"
}
}
}
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType {
switch connectionPlan {
case .invitationLink: return .invitation
case .contactAddress: return .contact
case .groupLink: return .groupLink
}
}
func connReqSentAlert(_ type: ConnReqType) -> Alert { func connReqSentAlert(_ type: ConnReqType) -> Alert {
return mkAlert( return mkAlert(
title: "Connection request sent!", title: "Connection request sent!",
message: type == .contact message: type.connReqSentText
? "You will be connected when your connection request is accepted, please wait or check later!"
: "You will be connected when your contact's device is online, please wait or check later!"
) )
} }
@@ -14,6 +14,8 @@ struct PasteToConnectView: View {
@State private var connectionLink: String = "" @State private var connectionLink: String = ""
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@FocusState private var linkEditorFocused: Bool @FocusState private var linkEditorFocused: Bool
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View { var body: some View {
List { List {
@@ -52,11 +54,15 @@ struct PasteToConnectView: View {
IncognitoToggle(incognitoEnabled: $incognitoDefault) IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: { } footer: {
sharedProfileInfo(incognitoDefault) VStack(alignment: .leading, spacing: 4) {
+ Text(String("\n\n")) sharedProfileInfo(incognitoDefault)
+ Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.") Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
}
.frame(maxWidth: .infinity, alignment: .leading)
} }
} }
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
} }
private func linkEditor() -> some View { private func linkEditor() -> some View {
@@ -83,13 +89,13 @@ struct PasteToConnectView: View {
private func connect() { private func connect() {
let link = connectionLink.trimmingCharacters(in: .whitespaces) let link = connectionLink.trimmingCharacters(in: .whitespaces)
if let crData = parseLinkQueryData(link), planAndConnect(
checkCRDataGroup(crData) { link,
dismiss() showAlert: { alert = $0 },
AlertManager.shared.showAlert(groupLinkAlert(link, incognito: incognitoDefault)) showActionSheet: { sheet = $0 },
} else { dismiss: true,
connectViaLink(link, dismiss: dismiss, incognito: incognitoDefault) incognito: incognitoDefault
} )
} }
} }
@@ -28,6 +28,22 @@ struct MutableQRCode: View {
} }
} }
struct SimpleXLinkQRCode: View {
let uri: String
var withLogo: Bool = true
var tintColor = UIColor(red: 0.023, green: 0.176, blue: 0.337, alpha: 1)
var body: some View {
QRCode(uri: simplexChatLink(uri), withLogo: withLogo, tintColor: tintColor)
}
}
func simplexChatLink(_ uri: String) -> String {
uri.starts(with: "simplex:/")
? uri.replacingOccurrences(of: "simplex:/", with: "https://simplex.chat/")
: uri
}
struct QRCode: View { struct QRCode: View {
let uri: String let uri: String
var withLogo: Bool = true var withLogo: Bool = true
@@ -13,6 +13,8 @@ import CodeScanner
struct ScanToConnectView: View { struct ScanToConnectView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var alert: PlanAndConnectAlert?
@State private var sheet: PlanAndConnectActionSheet?
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -36,11 +38,11 @@ struct ScanToConnectView: View {
) )
.padding(.top) .padding(.top)
Group { VStack(alignment: .leading, spacing: 4) {
sharedProfileInfo(incognitoDefault) sharedProfileInfo(incognitoDefault)
+ Text(String("\n\n")) Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
+ Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
} }
.frame(maxWidth: .infinity, alignment: .leading)
.font(.footnote) .font(.footnote)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.padding(.horizontal) .padding(.horizontal)
@@ -49,18 +51,20 @@ struct ScanToConnectView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
} }
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
} }
func processQRCode(_ resp: Result<ScanResult, ScanError>) { func processQRCode(_ resp: Result<ScanResult, ScanError>) {
switch resp { switch resp {
case let .success(r): case let .success(r):
if let crData = parseLinkQueryData(r.string), planAndConnect(
checkCRDataGroup(crData) { r.string,
dismiss() showAlert: { alert = $0 },
AlertManager.shared.showAlert(groupLinkAlert(r.string, incognito: incognitoDefault)) showActionSheet: { sheet = $0 },
} else { dismiss: true,
Task { connectViaLink(r.string, dismiss: dismiss, incognito: incognitoDefault) } incognito: incognitoDefault
} )
case let .failure(e): case let .failure(e):
logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)") logger.error("ConnectContactView.processQRCode QR code error: \(e.localizedDescription)")
dismiss() dismiss()
@@ -9,175 +9,244 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
enum UserProfileAlert: Identifiable {
case duplicateUserError
case createUserError(error: LocalizedStringKey)
case invalidNameError(validName: String)
var id: String {
switch self {
case .duplicateUserError: return "duplicateUserError"
case .createUserError: return "createUserError"
case let .invalidNameError(validName): return "invalidNameError \(validName)"
}
}
}
struct CreateProfile: View { struct CreateProfile: View {
@Environment(\.dismiss) var dismiss
@State private var displayName: String = ""
@FocusState private var focusDisplayName
@State private var alert: UserProfileAlert?
var body: some View {
List {
Section {
TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName)
Button {
createProfile(displayName, showAlert: { alert = $0 }, dismiss: dismiss)
} label: {
Label("Create profile", systemImage: "checkmark")
}
.disabled(!canCreateProfile(displayName))
} header: {
HStack {
Text("Your profile")
let name = displayName.trimmingCharacters(in: .whitespaces)
let validName = mkValidName(name)
if name != validName {
Spacer()
Image(systemName: "exclamationmark.circle")
.foregroundColor(.red)
.onTapGesture {
alert = .invalidNameError(validName: validName)
}
}
}
.frame(height: 20)
} footer: {
VStack(alignment: .leading, spacing: 8) {
Text("Your profile, contacts and delivered messages are stored on your device.")
Text("The profile is only shared with your contacts.")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.navigationTitle("Create your profile")
.alert(item: $alert) { a in userProfileAlert(a, $displayName) }
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true
}
}
.keyboardPadding()
}
}
struct CreateFirstProfile: View {
@EnvironmentObject var m: ChatModel @EnvironmentObject var m: ChatModel
@Environment(\.dismiss) var dismiss @Environment(\.dismiss) var dismiss
@State private var displayName: String = "" @State private var displayName: String = ""
@State private var fullName: String = ""
@FocusState private var focusDisplayName @FocusState private var focusDisplayName
@FocusState private var focusFullName
@State private var alert: CreateProfileAlert?
private enum CreateProfileAlert: Identifiable {
case duplicateUserError
case createUserError(error: LocalizedStringKey)
var id: String {
switch self {
case .duplicateUserError: return "duplicateUserError"
case .createUserError: return "createUserError"
}
}
}
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("Create your profile") Group {
.font(.largeTitle) Text("Create your profile")
.bold() .font(.largeTitle)
.padding(.bottom, 4) .bold()
.frame(maxWidth: .infinity) Text("Your profile, contacts and delivered messages are stored on your device.")
Text("Your profile, contacts and delivered messages are stored on your device.") .foregroundColor(.secondary)
.padding(.bottom, 4) Text("The profile is only shared with your contacts.")
Text("The profile is only shared with your contacts.") .foregroundColor(.secondary)
.padding(.bottom) .padding(.bottom)
}
.padding(.bottom)
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if !validDisplayName(displayName) { let name = displayName.trimmingCharacters(in: .whitespaces)
Image(systemName: "exclamationmark.circle") let validName = mkValidName(name)
.foregroundColor(.red) if name != validName {
.padding(.top, 4) Button {
showAlert(.invalidNameError(validName: validName))
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
} }
textField("Display name", text: $displayName) TextField("Enter your name", text: $displayName)
.focused($focusDisplayName) .focused($focusDisplayName)
.submitLabel(.next) .padding(.leading, 32)
.onSubmit {
if canCreateProfile() { focusFullName = true }
else { focusDisplayName = true }
}
} }
textField("Full name (optional)", text: $fullName) .padding(.bottom)
.focused($focusFullName)
.submitLabel(.go)
.onSubmit {
if canCreateProfile() { createProfile() }
else { focusFullName = true }
}
Spacer() Spacer()
onboardingButtons()
HStack {
if m.users.isEmpty {
Button {
hideKeyboard()
withAnimation {
m.onboardingStage = .step1_SimpleXInfo
}
} label: {
HStack {
Image(systemName: "lessthan")
Text("About SimpleX")
}
}
}
Spacer()
HStack {
Button {
createProfile()
} label: {
Text("Create")
Image(systemName: "greaterthan")
}
.disabled(!canCreateProfile())
}
}
} }
.onAppear() { .onAppear() {
focusDisplayName = true focusDisplayName = true
setLastVersionDefault() setLastVersionDefault()
} }
.alert(item: $alert) { a in
switch a {
case .duplicateUserError: return duplicateUserAlert
case let .createUserError(err): return creatUserErrorAlert(err)
}
}
.padding() .padding()
.frame(maxWidth: .infinity, alignment: .leading)
.keyboardPadding() .keyboardPadding()
} }
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View { func onboardingButtons() -> some View {
TextField(placeholder, text: text) HStack {
.textInputAutocapitalization(.never) Button {
.disableAutocorrection(true) hideKeyboard()
.padding(.leading, 28)
.padding(.bottom)
}
func createProfile() {
hideKeyboard()
let profile = Profile(
displayName: displayName,
fullName: fullName
)
do {
m.currentUser = try apiCreateActiveUser(profile)
if m.users.isEmpty {
try startChat()
withAnimation { withAnimation {
onboardingStageDefault.set(.step3_CreateSimpleXAddress) m.onboardingStage = .step1_SimpleXInfo
m.onboardingStage = .step3_CreateSimpleXAddress
} }
} else { } label: {
onboardingStageDefault.set(.onboardingComplete) HStack {
m.onboardingStage = .onboardingComplete Image(systemName: "lessthan")
dismiss() Text("About SimpleX")
m.users = try listUsers()
try getUserChatData()
}
} catch let error {
switch error as? ChatResponse {
case .chatCmdError(_, .errorStore(.duplicateName)),
.chatCmdError(_, .error(.userExists)):
if m.currentUser == nil {
AlertManager.shared.showAlert(duplicateUserAlert)
} else {
alert = .duplicateUserError
}
default:
let err: LocalizedStringKey = "Error: \(responseError(error))"
if m.currentUser == nil {
AlertManager.shared.showAlert(creatUserErrorAlert(err))
} else {
alert = .createUserError(error: err)
} }
} }
logger.error("Failed to create user or start chat: \(responseError(error))")
Spacer()
Button {
createProfile(displayName, showAlert: showAlert, dismiss: dismiss)
} label: {
HStack {
Text("Create")
Image(systemName: "greaterthan")
}
}
.disabled(!canCreateProfile(displayName))
} }
} }
func canCreateProfile() -> Bool { private func showAlert(_ alert: UserProfileAlert) {
displayName != "" && validDisplayName(displayName) AlertManager.shared.showAlert(userProfileAlert(alert, $displayName))
}
private var duplicateUserAlert: Alert {
Alert(
title: Text("Duplicate display name!"),
message: Text("You already have a chat profile with the same display name. Please choose another name.")
)
}
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
Alert(
title: Text("Error creating profile!"),
message: Text(err)
)
} }
} }
private func createProfile(_ displayName: String, showAlert: (UserProfileAlert) -> Void, dismiss: DismissAction) {
hideKeyboard()
let profile = Profile(
displayName: displayName.trimmingCharacters(in: .whitespaces),
fullName: ""
)
let m = ChatModel.shared
do {
m.currentUser = try apiCreateActiveUser(profile)
if m.users.isEmpty {
try startChat()
withAnimation {
onboardingStageDefault.set(.step3_CreateSimpleXAddress)
m.onboardingStage = .step3_CreateSimpleXAddress
}
} else {
onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete
dismiss()
m.users = try listUsers()
try getUserChatData()
}
} catch let error {
switch error as? ChatResponse {
case .chatCmdError(_, .errorStore(.duplicateName)),
.chatCmdError(_, .error(.userExists)):
if m.currentUser == nil {
AlertManager.shared.showAlert(duplicateUserAlert)
} else {
showAlert(.duplicateUserError)
}
default:
let err: LocalizedStringKey = "Error: \(responseError(error))"
if m.currentUser == nil {
AlertManager.shared.showAlert(creatUserErrorAlert(err))
} else {
showAlert(.createUserError(error: err))
}
}
logger.error("Failed to create user or start chat: \(responseError(error))")
}
}
private func canCreateProfile(_ displayName: String) -> Bool {
let name = displayName.trimmingCharacters(in: .whitespaces)
return name != "" && mkValidName(name) == name
}
func userProfileAlert(_ alert: UserProfileAlert, _ displayName: Binding<String>) -> Alert {
switch alert {
case .duplicateUserError: return duplicateUserAlert
case let .createUserError(err): return creatUserErrorAlert(err)
case let .invalidNameError(name): return createInvalidNameAlert(name, displayName)
}
}
private var duplicateUserAlert: Alert {
Alert(
title: Text("Duplicate display name!"),
message: Text("You already have a chat profile with the same display name. Please choose another name.")
)
}
private func creatUserErrorAlert(_ err: LocalizedStringKey) -> Alert {
Alert(
title: Text("Error creating profile!"),
message: Text(err)
)
}
func createInvalidNameAlert(_ name: String, _ displayName: Binding<String>) -> Alert {
name == ""
? Alert(title: Text("Invalid name!"))
: Alert(
title: Text("Invalid name!"),
message: Text("Correct name to \(name)?"),
primaryButton: .default(
Text("Ok"),
action: { displayName.wrappedValue = name }
),
secondaryButton: .cancel()
)
}
func validDisplayName(_ name: String) -> Bool { func validDisplayName(_ name: String) -> Bool {
name.firstIndex(of: " ") == nil && name.first != "@" && name.first != "#" mkValidName(name.trimmingCharacters(in: .whitespaces)) == name
}
func mkValidName(_ s: String) -> String {
var c = s.cString(using: .utf8)!
return fromCString(chat_valid_name(&c)!)
} }
struct CreateProfile_Previews: PreviewProvider { struct CreateProfile_Previews: PreviewProvider {
@@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
Spacer() Spacer()
if let userAddress = m.userAddress { if let userAddress = m.userAddress {
QRCode(uri: userAddress.connReqContact) SimpleXLinkQRCode(uri: userAddress.connReqContact)
.frame(maxHeight: g.size.width) .frame(maxHeight: g.size.width)
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -126,7 +126,7 @@ struct CreateSimpleXAddress: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button { Button {
showShareSheet(items: [userAddress.connReqContact]) showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
} label: { } label: {
Label("Share", systemImage: "square.and.arrow.up") Label("Share", systemImage: "square.and.arrow.up")
} }
@@ -194,7 +194,7 @@ struct SendAddressMailView: View {
let messageBody = String(format: NSLocalizedString(""" let messageBody = String(format: NSLocalizedString("""
<p>Hi!</p> <p>Hi!</p>
<p><a href="%@">Connect to me via SimpleX Chat</a></p> <p><a href="%@">Connect to me via SimpleX Chat</a></p>
""", comment: "email text"), userAddress.connReqContact) """, comment: "email text"), simplexChatLink(userAddress.connReqContact))
MailView( MailView(
isShowing: self.$showMailView, isShowing: self.$showMailView,
result: $mailViewResult, result: $mailViewResult,
@@ -14,7 +14,7 @@ struct OnboardingView: View {
var body: some View { var body: some View {
switch onboarding { switch onboarding {
case .step1_SimpleXInfo: SimpleXInfo(onboarding: true) case .step1_SimpleXInfo: SimpleXInfo(onboarding: true)
case .step2_CreateProfile: CreateProfile() case .step2_CreateProfile: CreateFirstProfile()
case .step3_CreateSimpleXAddress: CreateSimpleXAddress() case .step3_CreateSimpleXAddress: CreateSimpleXAddress()
case .step4_SetNotificationsMode: SetNotificationsMode() case .step4_SetNotificationsMode: SetNotificationsMode()
case .onboardingComplete: EmptyView() case .onboardingComplete: EmptyView()
@@ -66,6 +66,9 @@ struct PrivacySettings: View {
Section { Section {
settingsRow("lock.doc") { settingsRow("lock.doc") {
Toggle("Encrypt local files", isOn: $encryptLocalFiles) Toggle("Encrypt local files", isOn: $encryptLocalFiles)
.onChange(of: encryptLocalFiles) {
setEncryptLocalFiles($0)
}
} }
settingsRow("photo") { settingsRow("photo") {
Toggle("Auto-accept images", isOn: $autoAcceptImages) Toggle("Auto-accept images", isOn: $autoAcceptImages)
@@ -90,7 +93,9 @@ struct PrivacySettings: View {
} }
settingsRow("link") { settingsRow("link") {
Picker("SimpleX links", selection: $simplexLinkMode) { Picker("SimpleX links", selection: $simplexLinkMode) {
ForEach(SimpleXLinkMode.values) { mode in ForEach(
SimpleXLinkMode.values + (SimpleXLinkMode.values.contains(simplexLinkMode) ? [] : [simplexLinkMode])
) { mode in
Text(mode.text) Text(mode.text)
} }
} }
@@ -101,10 +106,6 @@ struct PrivacySettings: View {
} }
} header: { } header: {
Text("Chats") Text("Chats")
} footer: {
if case .browser = simplexLinkMode {
Text("Opening the link in the browser may reduce connection privacy and security. Untrusted SimpleX links will be red.")
}
} }
Section { Section {
@@ -118,7 +119,7 @@ struct PrivacySettings: View {
Text("Send delivery receipts to") Text("Send delivery receipts to")
} footer: { } footer: {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text("These settings are for your current profile **\(ChatModel.shared.currentUser?.displayName ?? "")**.") Text("These settings are for your current profile **\(m.currentUser?.displayName ?? "")**.")
Text("They can be overridden in contact and group settings.") Text("They can be overridden in contact and group settings.")
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@@ -183,6 +184,16 @@ struct PrivacySettings: View {
} }
} }
private func setEncryptLocalFiles(_ enable: Bool) {
do {
try apiSetEncryptLocalFiles(enable)
} catch let error {
let err = responseError(error)
logger.error("apiSetEncryptLocalFiles \(err)")
alert = .error(title: "Error", error: "\(err)")
}
}
private func setOrAskSendReceiptsContacts(_ enable: Bool) { private func setOrAskSendReceiptsContacts(_ enable: Bool) {
contactReceiptsOverrides = m.chats.reduce(0) { count, chat in contactReceiptsOverrides = m.chats.reduce(0) { count, chat in
let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts let sendRcpts = chat.chatInfo.contact?.chatSettings.sendRcpts
@@ -345,7 +356,7 @@ struct SimplexLockView: View {
var id: Self { self } var id: Self { self }
} }
let laDelays: [Int] = [10, 30, 60, 180, 0] let laDelays: [Int] = [10, 30, 60, 180, 600, 0]
func laDelayText(_ t: Int) -> LocalizedStringKey { func laDelayText(_ t: Int) -> LocalizedStringKey {
let m = t / 60 let m = t / 60
@@ -367,6 +378,7 @@ struct SimplexLockView: View {
Text(mode.text) Text(mode.text)
} }
} }
.frame(height: 36)
if performLA { if performLA {
Picker("Lock after", selection: $laLockDelay) { Picker("Lock after", selection: $laLockDelay) {
let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays let delays = laDelays.contains(laLockDelay) ? laDelays : [laLockDelay] + laDelays
@@ -374,6 +386,7 @@ struct SimplexLockView: View {
Text(laDelayText(t)) Text(laDelayText(t))
} }
} }
.frame(height: 36)
if showChangePassword && laMode == .passcode { if showChangePassword && laMode == .passcode {
Button("Change passcode") { Button("Change passcode") {
changeLAPassword() changeLAPassword()
@@ -93,7 +93,7 @@ enum SimpleXLinkMode: String, Identifiable {
case full case full
case browser case browser
static var values: [SimpleXLinkMode] = [.description, .full, .browser] static var values: [SimpleXLinkMode] = [.description, .full]
public var id: Self { self } public var id: Self { self }
@@ -381,7 +381,9 @@ struct ProfilePreview: View {
Text(profileOf.displayName) Text(profileOf.displayName)
.fontWeight(.bold) .fontWeight(.bold)
.font(.title2) .font(.title2)
Text(profileOf.fullName) if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
Text(profileOf.fullName)
}
} }
} }
} }
@@ -190,7 +190,7 @@ struct UserAddressView: View {
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section { Section {
MutableQRCode(uri: Binding.constant(userAddress.connReqContact)) MutableQRCode(uri: Binding.constant(simplexChatLink(userAddress.connReqContact)))
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
if MFMailComposeViewController.canSendMail() { if MFMailComposeViewController.canSendMail() {
shareViaEmailButton(userAddress) shareViaEmailButton(userAddress)
@@ -248,7 +248,7 @@ struct UserAddressView: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button { Button {
showShareSheet(items: [userAddress.connReqContact]) showShareSheet(items: [simplexChatLink(userAddress.connReqContact)])
} label: { } label: {
settingsRow("square.and.arrow.up") { settingsRow("square.and.arrow.up") {
Text("Share address") Text("Share address")
@@ -17,6 +17,8 @@ struct UserProfile: View {
@State private var showImagePicker = false @State private var showImagePicker = false
@State private var showTakePhoto = false @State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil @State private var chosenImage: UIImage? = nil
@State private var alert: UserProfileAlert?
@FocusState private var focusDisplayName
var body: some View { var body: some View {
let user: User = chatModel.currentUser! let user: User = chatModel.currentUser!
@@ -47,18 +49,27 @@ struct UserProfile: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
ZStack(alignment: .leading) { ZStack(alignment: .leading) {
if !validDisplayName(profile.displayName) { if !validNewProfileName(user) {
Image(systemName: "exclamationmark.circle") Button {
.foregroundColor(.red) alert = .invalidNameError(validName: mkValidName(profile.displayName))
.padding(.bottom, 10) } label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
} }
profileNameTextEdit("Display name", $profile.displayName) profileNameTextEdit("Profile name", $profile.displayName)
.focused($focusDisplayName)
}
.padding(.bottom)
if showFullName(user) {
profileNameTextEdit("Full name (optional)", $profile.fullName)
.padding(.bottom)
} }
profileNameTextEdit("Full name (optional)", $profile.fullName)
HStack(spacing: 20) { HStack(spacing: 20) {
Button("Cancel") { editProfile = false } Button("Cancel") { editProfile = false }
Button("Save (and notify contacts)") { saveProfile() } Button("Save (and notify contacts)") { saveProfile() }
.disabled(profile.displayName == "" || !validDisplayName(profile.displayName)) .disabled(!canSaveProfile(user))
} }
} }
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
@@ -74,11 +85,14 @@ struct UserProfile: View {
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
VStack(alignment: .leading) { VStack(alignment: .leading) {
profileNameView("Display name:", user.profile.displayName) profileNameView("Profile name:", user.profile.displayName)
profileNameView("Full name:", user.profile.fullName) if showFullName(user) {
profileNameView("Full name:", user.profile.fullName)
}
Button("Edit") { Button("Edit") {
profile = fromLocalProfile(user.profile) profile = fromLocalProfile(user.profile)
editProfile = true editProfile = true
focusDisplayName = true
} }
} }
.frame(maxWidth: .infinity, minHeight: 120, alignment: .leading) .frame(maxWidth: .infinity, minHeight: 120, alignment: .leading)
@@ -117,14 +131,12 @@ struct UserProfile: View {
profile.image = nil profile.image = nil
} }
} }
.alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) }
} }
func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View { func profileNameTextEdit(_ label: LocalizedStringKey, _ name: Binding<String>) -> some View {
TextField(label, text: name) TextField(label, text: name)
.textInputAutocapitalization(.never) .padding(.leading, 32)
.disableAutocorrection(true)
.padding(.bottom)
.padding(.leading, 28)
} }
func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View { func profileNameView(_ label: LocalizedStringKey, _ name: String) -> some View {
@@ -141,19 +153,34 @@ struct UserProfile: View {
showChooseSource = true showChooseSource = true
} }
private func validNewProfileName(_ user: User) -> Bool {
profile.displayName == user.profile.displayName || validDisplayName(profile.displayName.trimmingCharacters(in: .whitespaces))
}
private func showFullName(_ user: User) -> Bool {
user.profile.fullName != "" && user.profile.fullName != user.profile.displayName
}
private func canSaveProfile(_ user: User) -> Bool {
profile.displayName.trimmingCharacters(in: .whitespaces) != "" && validNewProfileName(user)
}
func saveProfile() { func saveProfile() {
Task { Task {
do { do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
if let (newProfile, _) = try await apiUpdateProfile(profile: profile) { if let (newProfile, _) = try await apiUpdateProfile(profile: profile) {
DispatchQueue.main.async { DispatchQueue.main.async {
chatModel.updateCurrentUser(newProfile) chatModel.updateCurrentUser(newProfile)
profile = newProfile profile = newProfile
} }
editProfile = false
} else {
alert = .duplicateUserError
} }
} catch { } catch {
logger.error("UserProfile apiUpdateProfile error: \(responseError(error))") logger.error("UserProfile apiUpdateProfile error: \(responseError(error))")
} }
editProfile = false
} }
} }
} }
@@ -216,6 +216,7 @@ func startChat() -> DBMigrationResult? {
try apiSetTempFolder(tempFolder: getTempFilesDirectory().path) try apiSetTempFolder(tempFolder: getTempFilesDirectory().path)
try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path) try apiSetFilesFolder(filesFolder: getAppFilesDirectory().path)
try setXFTPConfig(xftpConfig) try setXFTPConfig(xftpConfig)
try apiSetEncryptLocalFiles(privacyEncryptLocalFilesGroupDefault.get())
let justStarted = try apiStartChat() let justStarted = try apiStartChat()
chatStarted = true chatStarted = true
if justStarted { if justStarted {
@@ -351,6 +352,12 @@ func setXFTPConfig(_ cfg: XFTPFileConfig?) throws {
throw r throw r
} }
func apiSetEncryptLocalFiles(_ enable: Bool) throws {
let r = sendSimpleXCmd(.apiSetEncryptLocalFiles(enable: enable))
if case .cmdOk = r { return }
throw r
}
func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? { func apiGetNtfMessage(nonce: String, encNtfInfo: String) -> NtfMessages? {
guard apiGetActiveUser() != nil else { guard apiGetActiveUser() != nil else {
logger.debug("no active user") logger.debug("no active user")
+32 -36
View File
@@ -16,7 +16,6 @@
18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; }; 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415323A4082FC92887F906 /* WebRTCClient.swift */; };
18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; }; 18415F9A2D551F9757DA4654 /* CIVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */; };
18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; }; 18415FEFE153C5920BFB7828 /* GroupWelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1841516F0CE5992B0EDFB377 /* GroupWelcomeView.swift */; };
3C71477A281C0F6800CB4D4B /* www in Resources */ = {isa = PBXBuildFile; fileRef = 3C714779281C0F6800CB4D4B /* www */; };
3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; }; 3C8C548928133C84000A3EC7 /* PasteToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */; };
3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; }; 3CDBCF4227FAE51000354CDD /* ComposeLinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */; };
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; }; 3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CDBCF4727FF621E00354CDD /* CILinkView.swift */; };
@@ -48,11 +47,6 @@
5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; }; 5C55A921283CCCB700C4E99E /* IncomingCallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */; };
5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; }; 5C55A923283CEDE600C4E99E /* SoundPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */; };
5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; }; 5C55A92E283D0FDE00C4E99E /* sounds in Resources */ = {isa = PBXBuildFile; fileRef = 5C55A92D283D0FDE00C4E99E /* sounds */; };
5C5625102ABDFA8900A21210 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250B2ABDFA8900A21210 /* libffi.a */; };
5C5625112ABDFA8900A21210 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250C2ABDFA8900A21210 /* libgmp.a */; };
5C5625122ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */; };
5C5625132ABDFA8900A21210 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250E2ABDFA8900A21210 /* libgmpxx.a */; };
5C5625142ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */; };
5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; }; 5C577F7D27C83AA10006112D /* MarkdownHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */; };
5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; }; 5C58BCD6292BEBE600AF9E4F /* CIChatFeatureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */; };
5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; }; 5C5DB70E289ABDD200730FFF /* AppearanceSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C5DB70D289ABDD200730FFF /* AppearanceSettings.swift */; };
@@ -123,6 +117,11 @@
5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; }; 5CCB939C297EFCB100399E78 /* NavStackCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */; };
5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; }; 5CCD403427A5F6DF00368C90 /* AddContactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403327A5F6DF00368C90 /* AddContactView.swift */; };
5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; }; 5CCD403727A5F9A200368C90 /* ScanToConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */; };
5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */; };
5CD089322AE59CB300669208 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892D2AE59CB300669208 /* libffi.a */; };
5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892E2AE59CB300669208 /* libgmpxx.a */; };
5CD089342AE59CB300669208 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD0892F2AE59CB300669208 /* libgmp.a */; };
5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */; };
5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; }; 5CDCAD482818589900503DA2 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CDCAD472818589900503DA2 /* NotificationService.swift */; };
5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; }; 5CE2BA702845308900EC33A6 /* SimpleXChat.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; };
5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5CE2BA712845308900EC33A6 /* SimpleXChat.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@@ -257,7 +256,6 @@
18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = "<group>"; }; 18415B08031E8FB0F7FC27F9 /* CallViewRenderers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallViewRenderers.swift; sourceTree = "<group>"; };
18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; }; 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = "<group>"; }; 18415FD2E36F13F596A45BB4 /* CIVideoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CIVideoView.swift; sourceTree = "<group>"; };
3C714779281C0F6800CB4D4B /* www */ = {isa = PBXFileReference; lastKnownFileType = folder; name = www; path = ../multiplatform/android/src/main/assets/www; sourceTree = "<group>"; };
3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; }; 3C8C548828133C84000A3EC7 /* PasteToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteToConnectView.swift; sourceTree = "<group>"; };
3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; }; 3CDBCF4127FAE51000354CDD /* ComposeLinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeLinkView.swift; sourceTree = "<group>"; };
3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; }; 3CDBCF4727FF621E00354CDD /* CILinkView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CILinkView.swift; sourceTree = "<group>"; };
@@ -293,11 +291,6 @@
5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; }; 5C55A920283CCCB700C4E99E /* IncomingCallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IncomingCallView.swift; sourceTree = "<group>"; };
5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = "<group>"; }; 5C55A922283CEDE600C4E99E /* SoundPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoundPlayer.swift; sourceTree = "<group>"; };
5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = "<group>"; }; 5C55A92D283D0FDE00C4E99E /* sounds */ = {isa = PBXFileReference; lastKnownFileType = folder; path = sounds; sourceTree = "<group>"; };
5C56250B2ABDFA8900A21210 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5C56250C2ABDFA8900A21210 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a"; sourceTree = "<group>"; };
5C56250E2ABDFA8900A21210 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a"; sourceTree = "<group>"; };
5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; }; 5C577F7C27C83AA10006112D /* MarkdownHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownHelp.swift; sourceTree = "<group>"; };
5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = "<group>"; }; 5C58BCD5292BEBE600AF9E4F /* CIChatFeatureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIChatFeatureView.swift; sourceTree = "<group>"; };
5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; }; 5C5B67912ABAF4B500DA9412 /* bg */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bg; path = bg.lproj/Localizable.strings; sourceTree = "<group>"; };
@@ -404,6 +397,11 @@
5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; }; 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavStackCompat.swift; sourceTree = "<group>"; };
5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; }; 5CCD403327A5F6DF00368C90 /* AddContactView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactView.swift; sourceTree = "<group>"; };
5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; }; 5CCD403627A5F9A200368C90 /* ScanToConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanToConnectView.swift; sourceTree = "<group>"; };
5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a"; sourceTree = "<group>"; };
5CD0892D2AE59CB300669208 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
5CD0892E2AE59CB300669208 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
5CD0892F2AE59CB300669208 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a"; sourceTree = "<group>"; };
5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 5CDCAD452818589900503DA2 /* SimpleX NSE.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "SimpleX NSE.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; }; 5CDCAD472818589900503DA2 /* NotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 5CDCAD492818589900503DA2 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
@@ -507,13 +505,13 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5C5625132ABDFA8900A21210 /* libgmpxx.a in Frameworks */, 5CD089352AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a in Frameworks */,
5C5625122ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */, 5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
5C5625142ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a in Frameworks */, 5CD089332AE59CB300669208 /* libgmpxx.a in Frameworks */,
5C5625102ABDFA8900A21210 /* libffi.a in Frameworks */,
5C5625112ABDFA8900A21210 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */, 5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
5CD089312AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a in Frameworks */,
5CD089342AE59CB300669208 /* libgmp.a in Frameworks */,
5CD089322AE59CB300669208 /* libffi.a in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -574,11 +572,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = { 5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5C56250B2ABDFA8900A21210 /* libffi.a */, 5CD0892D2AE59CB300669208 /* libffi.a */,
5C56250C2ABDFA8900A21210 /* libgmp.a */, 5CD0892F2AE59CB300669208 /* libgmp.a */,
5C56250E2ABDFA8900A21210 /* libgmpxx.a */, 5CD0892E2AE59CB300669208 /* libgmpxx.a */,
5C56250F2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m-ghc8.10.7.a */, 5CD0892C2AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO-ghc8.10.7.a */,
5C56250D2ABDFA8900A21210 /* libHSsimplex-chat-5.3.0.10-9vCXcrdx54qEnAYPtVuU9m.a */, 5CD089302AE59CB300669208 /* libHSsimplex-chat-5.4.0.2-d5Ky77yoZRFE1pplaEhZO.a */,
); );
path = Libraries; path = Libraries;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -638,7 +636,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
5C55A92D283D0FDE00C4E99E /* sounds */, 5C55A92D283D0FDE00C4E99E /* sounds */,
3C714779281C0F6800CB4D4B /* www */,
5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */, 5CC2C0FD2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings */,
5CC2C0FA2809BF11000C35E3 /* Localizable.strings */, 5CC2C0FA2809BF11000C35E3 /* Localizable.strings */,
5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */, 5C422A7C27A9A6FA0097A1E1 /* SimpleX (iOS).entitlements */,
@@ -1050,7 +1047,6 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
5C55A92E283D0FDE00C4E99E /* sounds in Resources */, 5C55A92E283D0FDE00C4E99E /* sounds in Resources */,
3C71477A281C0F6800CB4D4B /* www in Resources */,
5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */, 5CA059EF279559F40002BEB4 /* Assets.xcassets in Resources */,
5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */, 5CC2C0FC2809BF11000C35E3 /* Localizable.strings in Resources */,
5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */, 5CC2C0FF2809BF11000C35E3 /* SimpleX--iOS--InfoPlist.strings in Resources */,
@@ -1486,7 +1482,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173; CURRENT_PROJECT_VERSION = 180;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1507,7 +1503,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.3; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1528,7 +1524,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173; CURRENT_PROJECT_VERSION = 180;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
@@ -1549,7 +1545,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 5.3; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX; PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1608,7 +1604,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173; CURRENT_PROJECT_VERSION = 180;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1621,7 +1617,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.3; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1640,7 +1636,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements"; CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173; CURRENT_PROJECT_VERSION = 180;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
@@ -1653,7 +1649,7 @@
"@executable_path/Frameworks", "@executable_path/Frameworks",
"@executable_path/../../Frameworks", "@executable_path/../../Frameworks",
); );
MARKETING_VERSION = 5.3; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE"; PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = ""; PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1672,7 +1668,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173; CURRENT_PROJECT_VERSION = 180;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1696,7 +1692,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.3; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -1718,7 +1714,7 @@
APPLICATION_EXTENSION_API_ONLY = YES; APPLICATION_EXTENSION_API_ONLY = YES;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 173; CURRENT_PROJECT_VERSION = 180;
DEFINES_MODULE = YES; DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T; DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1742,7 +1738,7 @@
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Libraries/sim", "$(PROJECT_DIR)/Libraries/sim",
); );
MARKETING_VERSION = 5.3; MARKETING_VERSION = 5.4;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat; PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
+11 -1
View File
@@ -50,6 +50,13 @@ public func chatMigrateInit(_ useKey: String? = nil, confirmMigrations: Migratio
return result return result
} }
public func chatCloseStore() {
let err = fromCString(chat_close_store(getChatCtrl()))
if err != "" {
logger.error("chatCloseStore error: \(err)")
}
}
public func resetChatCtrl() { public func resetChatCtrl() {
chatController = nil chatController = nil
migrationResult = nil migrationResult = nil
@@ -132,8 +139,11 @@ public func chatResponse(_ s: String) -> ChatResponse {
var type: String? var type: String?
var json: String? var json: String?
if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary { if let j = try? JSONSerialization.jsonObject(with: d) as? NSDictionary {
if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 { if let jResp = j["resp"] as? NSDictionary, jResp.count == 1 || jResp.count == 2 {
type = jResp.allKeys[0] as? String type = jResp.allKeys[0] as? String
if jResp.count == 2 && type == "_owsf" {
type = jResp.allKeys[1] as? String
}
if type == "apiChats" { if type == "apiChats" {
if let jApiChats = jResp["apiChats"] as? NSDictionary, if let jApiChats = jResp["apiChats"] as? NSDictionary,
let user: UserRef = try? decodeObject(jApiChats["user"] as Any), let user: UserRef = try? decodeObject(jApiChats["user"] as Any),
+135 -20
View File
@@ -32,6 +32,7 @@ public enum ChatCommand {
case setTempFolder(tempFolder: String) case setTempFolder(tempFolder: String)
case setFilesFolder(filesFolder: String) case setFilesFolder(filesFolder: String)
case apiSetXFTPConfig(config: XFTPFileConfig?) case apiSetXFTPConfig(config: XFTPFileConfig?)
case apiSetEncryptLocalFiles(enable: Bool)
case apiExportArchive(config: ArchiveConfig) case apiExportArchive(config: ArchiveConfig)
case apiImportArchive(config: ArchiveConfig) case apiImportArchive(config: ArchiveConfig)
case apiDeleteStorage case apiDeleteStorage
@@ -49,7 +50,7 @@ public enum ChatCommand {
case apiVerifyToken(token: DeviceToken, nonce: String, code: String) case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
case apiDeleteToken(token: DeviceToken) case apiDeleteToken(token: DeviceToken)
case apiGetNtfMessage(nonce: String, encNtfInfo: String) case apiGetNtfMessage(nonce: String, encNtfInfo: String)
case apiNewGroup(userId: Int64, groupProfile: GroupProfile) case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole) case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64) case apiJoinGroup(groupId: Int64)
case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole) case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole)
@@ -72,6 +73,7 @@ public enum ChatCommand {
case apiGetNetworkConfig case apiGetNetworkConfig
case reconnectAllServers case reconnectAllServers
case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings) case apiSetChatSettings(type: ChatType, id: Int64, chatSettings: ChatSettings)
case apiSetMemberSettings(groupId: Int64, groupMemberId: Int64, memberSettings: GroupMemberSettings)
case apiContactInfo(contactId: Int64) case apiContactInfo(contactId: Int64)
case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64) case apiGroupMemberInfo(groupId: Int64, groupMemberId: Int64)
case apiSwitchContact(contactId: Int64) case apiSwitchContact(contactId: Int64)
@@ -86,8 +88,9 @@ public enum ChatCommand {
case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?) case apiVerifyGroupMember(groupId: Int64, groupMemberId: Int64, connectionCode: String?)
case apiAddContact(userId: Int64, incognito: Bool) case apiAddContact(userId: Int64, incognito: Bool)
case apiSetConnectionIncognito(connId: Int64, incognito: Bool) case apiSetConnectionIncognito(connId: Int64, incognito: Bool)
case apiConnectPlan(userId: Int64, connReq: String)
case apiConnect(userId: Int64, incognito: Bool, connReq: String) case apiConnect(userId: Int64, incognito: Bool, connReq: String)
case apiDeleteChat(type: ChatType, id: Int64) case apiDeleteChat(type: ChatType, id: Int64, notify: Bool?)
case apiClearChat(type: ChatType, id: Int64) case apiClearChat(type: ChatType, id: Int64)
case apiListContacts(userId: Int64) case apiListContacts(userId: Int64)
case apiUpdateProfile(userId: Int64, profile: Profile) case apiUpdateProfile(userId: Int64, profile: Profile)
@@ -110,10 +113,11 @@ public enum ChatCommand {
case apiEndCall(contact: Contact) case apiEndCall(contact: Contact)
case apiGetCallInvitations case apiGetCallInvitations
case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus) case apiCallStatus(contact: Contact, callStatus: WebRTCCallStatus)
case apiGetNetworkStatuses
case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64)) case apiChatRead(type: ChatType, id: Int64, itemRange: (Int64, Int64))
case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) case apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool)
case receiveFile(fileId: Int64, encrypted: Bool, inline: Bool?) case receiveFile(fileId: Int64, encrypted: Bool?, inline: Bool?)
case setFileToReceive(fileId: Int64, encrypted: Bool) case setFileToReceive(fileId: Int64, encrypted: Bool?)
case cancelFile(fileId: Int64) case cancelFile(fileId: Int64)
case showVersion case showVersion
case string(String) case string(String)
@@ -150,6 +154,7 @@ public enum ChatCommand {
} else { } else {
return "/_xftp off" return "/_xftp off"
} }
case let .apiSetEncryptLocalFiles(enable): return "/_files_encrypt \(onOff(enable))"
case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))" case let .apiExportArchive(cfg): return "/_db export \(encodeJSON(cfg))"
case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))" case let .apiImportArchive(cfg): return "/_db import \(encodeJSON(cfg))"
case .apiDeleteStorage: return "/_db delete" case .apiDeleteStorage: return "/_db delete"
@@ -171,7 +176,7 @@ public enum ChatCommand {
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)" case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)" case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)"
case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)" case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)"
case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))" case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)" case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)" case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)" case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)"
@@ -194,6 +199,7 @@ public enum ChatCommand {
case .apiGetNetworkConfig: return "/network" case .apiGetNetworkConfig: return "/network"
case .reconnectAllServers: return "/reconnect" case .reconnectAllServers: return "/reconnect"
case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))" case let .apiSetChatSettings(type, id, chatSettings): return "/_settings \(ref(type, id)) \(encodeJSON(chatSettings))"
case let .apiSetMemberSettings(groupId, groupMemberId, memberSettings): return "/_member settings #\(groupId) \(groupMemberId) \(encodeJSON(memberSettings))"
case let .apiContactInfo(contactId): return "/_info @\(contactId)" case let .apiContactInfo(contactId): return "/_info @\(contactId)"
case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)" case let .apiGroupMemberInfo(groupId, groupMemberId): return "/_info #\(groupId) \(groupMemberId)"
case let .apiSwitchContact(contactId): return "/_switch @\(contactId)" case let .apiSwitchContact(contactId): return "/_switch @\(contactId)"
@@ -218,8 +224,13 @@ public enum ChatCommand {
case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)" case let .apiVerifyGroupMember(groupId, groupMemberId, .none): return "/_verify code #\(groupId) \(groupMemberId)"
case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))" case let .apiAddContact(userId, incognito): return "/_connect \(userId) incognito=\(onOff(incognito))"
case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))" case let .apiSetConnectionIncognito(connId, incognito): return "/_set incognito :\(connId) \(onOff(incognito))"
case let .apiConnectPlan(userId, connReq): return "/_connect plan \(userId) \(connReq)"
case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)" case let .apiConnect(userId, incognito, connReq): return "/_connect \(userId) incognito=\(onOff(incognito)) \(connReq)"
case let .apiDeleteChat(type, id): return "/_delete \(ref(type, id))" case let .apiDeleteChat(type, id, notify): if let notify = notify {
return "/_delete \(ref(type, id)) notify=\(onOff(notify))"
} else {
return "/_delete \(ref(type, id))"
}
case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))" case let .apiClearChat(type, id): return "/_clear chat \(ref(type, id))"
case let .apiListContacts(userId): return "/_contacts \(userId)" case let .apiListContacts(userId): return "/_contacts \(userId)"
case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))" case let .apiUpdateProfile(userId, profile): return "/_profile \(userId) \(encodeJSON(profile))"
@@ -241,15 +252,11 @@ public enum ChatCommand {
case let .apiEndCall(contact): return "/_call end @\(contact.apiId)" case let .apiEndCall(contact): return "/_call end @\(contact.apiId)"
case .apiGetCallInvitations: return "/_call get" case .apiGetCallInvitations: return "/_call get"
case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)" case let .apiCallStatus(contact, callStatus): return "/_call status @\(contact.apiId) \(callStatus.rawValue)"
case .apiGetNetworkStatuses: return "/_network_statuses"
case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)" case let .apiChatRead(type, id, itemRange: (from, to)): return "/_read chat \(ref(type, id)) from=\(from) to=\(to)"
case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))" case let .apiChatUnread(type, id, unreadChat): return "/_unread chat \(ref(type, id)) \(onOff(unreadChat))"
case let .receiveFile(fileId, encrypted, inline): case let .receiveFile(fileId, encrypt, inline): return "/freceive \(fileId)\(onOffParam("encrypt", encrypt))\(onOffParam("inline", inline))"
let s = "/freceive \(fileId) encrypt=\(onOff(encrypted))" case let .setFileToReceive(fileId, encrypt): return "/_set_file_to_receive \(fileId)\(onOffParam("encrypt", encrypt))"
if let inline = inline {
return s + " inline=\(onOff(inline))"
}
return s
case let .setFileToReceive(fileId, encrypted): return "/_set_file_to_receive \(fileId) encrypt=\(onOff(encrypted))"
case let .cancelFile(fileId): return "/fcancel \(fileId)" case let .cancelFile(fileId): return "/fcancel \(fileId)"
case .showVersion: return "/version" case .showVersion: return "/version"
case let .string(str): return str case let .string(str): return str
@@ -279,6 +286,7 @@ public enum ChatCommand {
case .setTempFolder: return "setTempFolder" case .setTempFolder: return "setTempFolder"
case .setFilesFolder: return "setFilesFolder" case .setFilesFolder: return "setFilesFolder"
case .apiSetXFTPConfig: return "apiSetXFTPConfig" case .apiSetXFTPConfig: return "apiSetXFTPConfig"
case .apiSetEncryptLocalFiles: return "apiSetEncryptLocalFiles"
case .apiExportArchive: return "apiExportArchive" case .apiExportArchive: return "apiExportArchive"
case .apiImportArchive: return "apiImportArchive" case .apiImportArchive: return "apiImportArchive"
case .apiDeleteStorage: return "apiDeleteStorage" case .apiDeleteStorage: return "apiDeleteStorage"
@@ -319,6 +327,7 @@ public enum ChatCommand {
case .apiGetNetworkConfig: return "apiGetNetworkConfig" case .apiGetNetworkConfig: return "apiGetNetworkConfig"
case .reconnectAllServers: return "reconnectAllServers" case .reconnectAllServers: return "reconnectAllServers"
case .apiSetChatSettings: return "apiSetChatSettings" case .apiSetChatSettings: return "apiSetChatSettings"
case .apiSetMemberSettings: return "apiSetMemberSettings"
case .apiContactInfo: return "apiContactInfo" case .apiContactInfo: return "apiContactInfo"
case .apiGroupMemberInfo: return "apiGroupMemberInfo" case .apiGroupMemberInfo: return "apiGroupMemberInfo"
case .apiSwitchContact: return "apiSwitchContact" case .apiSwitchContact: return "apiSwitchContact"
@@ -333,6 +342,7 @@ public enum ChatCommand {
case .apiVerifyGroupMember: return "apiVerifyGroupMember" case .apiVerifyGroupMember: return "apiVerifyGroupMember"
case .apiAddContact: return "apiAddContact" case .apiAddContact: return "apiAddContact"
case .apiSetConnectionIncognito: return "apiSetConnectionIncognito" case .apiSetConnectionIncognito: return "apiSetConnectionIncognito"
case .apiConnectPlan: return "apiConnectPlan"
case .apiConnect: return "apiConnect" case .apiConnect: return "apiConnect"
case .apiDeleteChat: return "apiDeleteChat" case .apiDeleteChat: return "apiDeleteChat"
case .apiClearChat: return "apiClearChat" case .apiClearChat: return "apiClearChat"
@@ -356,6 +366,7 @@ public enum ChatCommand {
case .apiEndCall: return "apiEndCall" case .apiEndCall: return "apiEndCall"
case .apiGetCallInvitations: return "apiGetCallInvitations" case .apiGetCallInvitations: return "apiGetCallInvitations"
case .apiCallStatus: return "apiCallStatus" case .apiCallStatus: return "apiCallStatus"
case .apiGetNetworkStatuses: return "apiGetNetworkStatuses"
case .apiChatRead: return "apiChatRead" case .apiChatRead: return "apiChatRead"
case .apiChatUnread: return "apiChatUnread" case .apiChatUnread: return "apiChatUnread"
case .receiveFile: return "receiveFile" case .receiveFile: return "receiveFile"
@@ -414,6 +425,13 @@ public enum ChatCommand {
b ? "on" : "off" b ? "on" : "off"
} }
private func onOffParam(_ param: String, _ b: Bool?) -> String {
if let b = b {
return " \(param)=\(onOff(b))"
}
return ""
}
private func maybePwd(_ pwd: String?) -> String { private func maybePwd(_ pwd: String?) -> String {
pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd) pwd == "" || pwd == nil ? "" : " " + encodeJSON(pwd)
} }
@@ -457,11 +475,13 @@ public enum ChatResponse: Decodable, Error {
case connectionVerified(user: UserRef, verified: Bool, expectedCode: String) case connectionVerified(user: UserRef, verified: Bool, expectedCode: String)
case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection) case invitation(user: UserRef, connReqInvitation: String, connection: PendingContactConnection)
case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection) case connectionIncognitoUpdated(user: UserRef, toConnection: PendingContactConnection)
case connectionPlan(user: UserRef, connectionPlan: ConnectionPlan)
case sentConfirmation(user: UserRef) case sentConfirmation(user: UserRef)
case sentInvitation(user: UserRef) case sentInvitation(user: UserRef)
case contactAlreadyExists(user: UserRef, contact: Contact) case contactAlreadyExists(user: UserRef, contact: Contact)
case contactRequestAlreadyAccepted(user: UserRef, contact: Contact) case contactRequestAlreadyAccepted(user: UserRef, contact: Contact)
case contactDeleted(user: UserRef, contact: Contact) case contactDeleted(user: UserRef, contact: Contact)
case contactDeletedByContact(user: UserRef, contact: Contact)
case chatCleared(user: UserRef, chatInfo: ChatInfo) case chatCleared(user: UserRef, chatInfo: ChatInfo)
case userProfileNoChange(user: User) case userProfileNoChange(user: User)
case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary)
@@ -479,11 +499,15 @@ public enum ChatResponse: Decodable, Error {
case acceptingContactRequest(user: UserRef, contact: Contact) case acceptingContactRequest(user: UserRef, contact: Contact)
case contactRequestRejected(user: UserRef) case contactRequestRejected(user: UserRef)
case contactUpdated(user: UserRef, toContact: Contact) case contactUpdated(user: UserRef, toContact: Contact)
case groupMemberUpdated(user: UserRef, groupInfo: GroupInfo, fromMember: GroupMember, toMember: GroupMember)
// TODO remove events below
case contactsSubscribed(server: String, contactRefs: [ContactRef]) case contactsSubscribed(server: String, contactRefs: [ContactRef])
case contactsDisconnected(server: String, contactRefs: [ContactRef]) case contactsDisconnected(server: String, contactRefs: [ContactRef])
case contactSubError(user: UserRef, contact: Contact, chatError: ChatError)
case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus]) case contactSubSummary(user: UserRef, contactSubscriptions: [ContactSubStatus])
case groupSubscribed(user: UserRef, groupInfo: GroupInfo) // TODO remove events above
case networkStatus(networkStatus: NetworkStatus, connections: [String])
case networkStatuses(user_: UserRef?, networkStatuses: [ConnNetworkStatus])
case groupSubscribed(user: UserRef, groupInfo: GroupRef)
case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError]) case memberSubErrors(user: UserRef, memberSubErrors: [MemberSubError])
case groupEmpty(user: UserRef, groupInfo: GroupInfo) case groupEmpty(user: UserRef, groupInfo: GroupInfo)
case userContactLinkSubscribed case userContactLinkSubscribed
@@ -498,6 +522,7 @@ public enum ChatResponse: Decodable, Error {
case groupCreated(user: UserRef, groupInfo: GroupInfo) case groupCreated(user: UserRef, groupInfo: GroupInfo)
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember) case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?) case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
case groupLinkConnecting(user: UserRef, groupInfo: GroupInfo, hostMember: GroupMember)
case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case userDeletedMember(user: UserRef, groupInfo: GroupInfo, member: GroupMember)
case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case leftMemberUser(user: UserRef, groupInfo: GroupInfo)
case groupMembers(user: UserRef, group: Group) case groupMembers(user: UserRef, group: Group)
@@ -594,11 +619,13 @@ public enum ChatResponse: Decodable, Error {
case .connectionVerified: return "connectionVerified" case .connectionVerified: return "connectionVerified"
case .invitation: return "invitation" case .invitation: return "invitation"
case .connectionIncognitoUpdated: return "connectionIncognitoUpdated" case .connectionIncognitoUpdated: return "connectionIncognitoUpdated"
case .connectionPlan: return "connectionPlan"
case .sentConfirmation: return "sentConfirmation" case .sentConfirmation: return "sentConfirmation"
case .sentInvitation: return "sentInvitation" case .sentInvitation: return "sentInvitation"
case .contactAlreadyExists: return "contactAlreadyExists" case .contactAlreadyExists: return "contactAlreadyExists"
case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted" case .contactRequestAlreadyAccepted: return "contactRequestAlreadyAccepted"
case .contactDeleted: return "contactDeleted" case .contactDeleted: return "contactDeleted"
case .contactDeletedByContact: return "contactDeletedByContact"
case .chatCleared: return "chatCleared" case .chatCleared: return "chatCleared"
case .userProfileNoChange: return "userProfileNoChange" case .userProfileNoChange: return "userProfileNoChange"
case .userProfileUpdated: return "userProfileUpdated" case .userProfileUpdated: return "userProfileUpdated"
@@ -616,10 +643,12 @@ public enum ChatResponse: Decodable, Error {
case .acceptingContactRequest: return "acceptingContactRequest" case .acceptingContactRequest: return "acceptingContactRequest"
case .contactRequestRejected: return "contactRequestRejected" case .contactRequestRejected: return "contactRequestRejected"
case .contactUpdated: return "contactUpdated" case .contactUpdated: return "contactUpdated"
case .groupMemberUpdated: return "groupMemberUpdated"
case .contactsSubscribed: return "contactsSubscribed" case .contactsSubscribed: return "contactsSubscribed"
case .contactsDisconnected: return "contactsDisconnected" case .contactsDisconnected: return "contactsDisconnected"
case .contactSubError: return "contactSubError"
case .contactSubSummary: return "contactSubSummary" case .contactSubSummary: return "contactSubSummary"
case .networkStatus: return "networkStatus"
case .networkStatuses: return "networkStatuses"
case .groupSubscribed: return "groupSubscribed" case .groupSubscribed: return "groupSubscribed"
case .memberSubErrors: return "memberSubErrors" case .memberSubErrors: return "memberSubErrors"
case .groupEmpty: return "groupEmpty" case .groupEmpty: return "groupEmpty"
@@ -634,6 +663,7 @@ public enum ChatResponse: Decodable, Error {
case .groupCreated: return "groupCreated" case .groupCreated: return "groupCreated"
case .sentGroupInvitation: return "sentGroupInvitation" case .sentGroupInvitation: return "sentGroupInvitation"
case .userAcceptedGroupSent: return "userAcceptedGroupSent" case .userAcceptedGroupSent: return "userAcceptedGroupSent"
case .groupLinkConnecting: return "groupLinkConnecting"
case .userDeletedMember: return "userDeletedMember" case .userDeletedMember: return "userDeletedMember"
case .leftMemberUser: return "leftMemberUser" case .leftMemberUser: return "leftMemberUser"
case .groupMembers: return "groupMembers" case .groupMembers: return "groupMembers"
@@ -730,11 +760,13 @@ public enum ChatResponse: Decodable, Error {
case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)") case let .connectionVerified(u, verified, expectedCode): return withUser(u, "verified: \(verified)\nconnectionCode: \(expectedCode)")
case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation) case let .invitation(u, connReqInvitation, _): return withUser(u, connReqInvitation)
case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection)) case let .connectionIncognitoUpdated(u, toConnection): return withUser(u, String(describing: toConnection))
case let .connectionPlan(u, connectionPlan): return withUser(u, String(describing: connectionPlan))
case .sentConfirmation: return noDetails case .sentConfirmation: return noDetails
case .sentInvitation: return noDetails case .sentInvitation: return noDetails
case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact)) case let .contactAlreadyExists(u, contact): return withUser(u, String(describing: contact))
case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact)) case let .contactRequestAlreadyAccepted(u, contact): return withUser(u, String(describing: contact))
case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) case let .contactDeleted(u, contact): return withUser(u, String(describing: contact))
case let .contactDeletedByContact(u, contact): return withUser(u, String(describing: contact))
case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo))
case .userProfileNoChange: return noDetails case .userProfileNoChange: return noDetails
case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile))
@@ -752,10 +784,12 @@ public enum ChatResponse: Decodable, Error {
case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact)) case let .acceptingContactRequest(u, contact): return withUser(u, String(describing: contact))
case .contactRequestRejected: return noDetails case .contactRequestRejected: return noDetails
case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact)) case let .contactUpdated(u, toContact): return withUser(u, String(describing: toContact))
case let .groupMemberUpdated(u, groupInfo, fromMember, toMember): return withUser(u, "groupInfo: \(groupInfo)\nfromMember: \(fromMember)\ntoMember: \(toMember)")
case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsSubscribed(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))" case let .contactsDisconnected(server, contactRefs): return "server: \(server)\ncontacts:\n\(String(describing: contactRefs))"
case let .contactSubError(u, contact, chatError): return withUser(u, "contact:\n\(String(describing: contact))\nerror:\n\(String(describing: chatError))")
case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions)) case let .contactSubSummary(u, contactSubscriptions): return withUser(u, String(describing: contactSubscriptions))
case let .networkStatus(status, conns): return "networkStatus: \(String(describing: status))\nconnections: \(String(describing: conns))"
case let .networkStatuses(u, statuses): return withUser(u, String(describing: statuses))
case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupSubscribed(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors)) case let .memberSubErrors(u, memberSubErrors): return withUser(u, String(describing: memberSubErrors))
case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupEmpty(u, groupInfo): return withUser(u, String(describing: groupInfo))
@@ -770,6 +804,7 @@ public enum ChatResponse: Decodable, Error {
case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupCreated(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)") case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))") case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
case let .groupLinkConnecting(u, groupInfo, hostMember): return withUser(u, "groupInfo: \(groupInfo)\nhostMember: \(String(describing: hostMember))")
case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .userDeletedMember(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)")
case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo))
case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .groupMembers(u, group): return withUser(u, String(describing: group))
@@ -848,6 +883,35 @@ public func chatError(_ chatResponse: ChatResponse) -> ChatErrorType? {
} }
} }
public enum ConnectionPlan: Decodable {
case invitationLink(invitationLinkPlan: InvitationLinkPlan)
case contactAddress(contactAddressPlan: ContactAddressPlan)
case groupLink(groupLinkPlan: GroupLinkPlan)
}
public enum InvitationLinkPlan: Decodable {
case ok
case ownLink
case connecting(contact_: Contact?)
case known(contact: Contact)
}
public enum ContactAddressPlan: Decodable {
case ok
case ownLink
case connectingConfirmReconnect
case connectingProhibit(contact: Contact)
case known(contact: Contact)
}
public enum GroupLinkPlan: Decodable {
case ok
case ownLink(groupInfo: GroupInfo)
case connectingConfirmReconnect
case connectingProhibit(groupInfo_: GroupInfo?)
case known(groupInfo: GroupInfo)
}
struct NewUser: Encodable { struct NewUser: Encodable {
var profile: Profile? var profile: Profile?
var sameServers: Bool var sameServers: Bool
@@ -1178,18 +1242,67 @@ public struct KeepAliveOpts: Codable, Equatable {
public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4) public static let defaults: KeepAliveOpts = KeepAliveOpts(keepIdle: 30, keepIntvl: 15, keepCnt: 4)
} }
public enum NetworkStatus: Decodable, Equatable {
case unknown
case connected
case disconnected
case error(connectionError: String)
public var statusString: LocalizedStringKey {
get {
switch self {
case .connected: return "connected"
case .error: return "error"
default: return "connecting"
}
}
}
public var statusExplanation: LocalizedStringKey {
get {
switch self {
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."
}
}
}
public var imageName: String {
get {
switch self {
case .unknown: return "circle.dotted"
case .connected: return "circle.fill"
case .disconnected: return "ellipsis.circle.fill"
case .error: return "exclamationmark.circle.fill"
}
}
}
}
public struct ConnNetworkStatus: Decodable {
public var agentConnId: String
public var networkStatus: NetworkStatus
}
public struct ChatSettings: Codable { public struct ChatSettings: Codable {
public var enableNtfs: Bool public var enableNtfs: MsgFilter
public var sendRcpts: Bool? public var sendRcpts: Bool?
public var favorite: Bool public var favorite: Bool
public init(enableNtfs: Bool, sendRcpts: Bool?, favorite: Bool) { public init(enableNtfs: MsgFilter, sendRcpts: Bool?, favorite: Bool) {
self.enableNtfs = enableNtfs self.enableNtfs = enableNtfs
self.sendRcpts = sendRcpts self.sendRcpts = sendRcpts
self.favorite = favorite self.favorite = favorite
} }
public static let defaults: ChatSettings = ChatSettings(enableNtfs: true, sendRcpts: nil, favorite: false) public static let defaults: ChatSettings = ChatSettings(enableNtfs: .all, sendRcpts: nil, favorite: false)
}
public enum MsgFilter: String, Codable {
case none
case all
case mentions
} }
public struct UserMsgReceiptSettings: Codable { public struct UserMsgReceiptSettings: Codable {
@@ -1417,9 +1530,11 @@ public enum ChatErrorType: Decodable {
case chatNotStarted case chatNotStarted
case chatNotStopped case chatNotStopped
case chatStoreChanged case chatStoreChanged
case connectionPlan(connectionPlan: ConnectionPlan)
case invalidConnReq case invalidConnReq
case invalidChatMessage(connection: Connection, message: String) case invalidChatMessage(connection: Connection, message: String)
case contactNotReady(contact: Contact) case contactNotReady(contact: Contact)
case contactNotActive(contact: Contact)
case contactDisabled(contact: Contact) case contactDisabled(contact: Contact)
case connectionDisabled(connection: Connection) case connectionDisabled(connection: Connection)
case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole) case groupUserRole(groupInfo: GroupInfo, requiredRole: GroupMemberRole)
+8
View File
@@ -80,6 +80,14 @@ public enum AppState: String {
default: return false default: return false
} }
} }
public var canSuspend: Bool {
switch self {
case .active: return true
case .bgRefresh: return true
default: return false
}
}
} }
public enum DBContainer: String { public enum DBContainer: String {
+89 -12
View File
@@ -422,8 +422,8 @@ public enum CustomTimeUnit {
public func timeText(_ seconds: Int?) -> String { public func timeText(_ seconds: Int?) -> String {
guard let seconds = seconds else { return "off" } guard let seconds = seconds else { return NSLocalizedString("off", comment: "time to disappear") }
if seconds == 0 { return "0 sec" } if seconds == 0 { return NSLocalizedString("0 sec", comment: "time to disappear") }
return CustomTimeUnit.toText(seconds: seconds) return CustomTimeUnit.toText(seconds: seconds)
} }
@@ -1292,7 +1292,7 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat {
} }
public var ntfsEnabled: Bool { public var ntfsEnabled: Bool {
self.chatSettings?.enableNtfs ?? false self.chatSettings?.enableNtfs == .all
} }
public var chatSettings: ChatSettings? { public var chatSettings: ChatSettings? {
@@ -1373,6 +1373,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var activeConn: Connection public var activeConn: Connection
public var viaGroup: Int64? public var viaGroup: Int64?
public var contactUsed: Bool public var contactUsed: Bool
public var contactStatus: ContactStatus
public var chatSettings: ChatSettings public var chatSettings: ChatSettings
public var userPreferences: Preferences public var userPreferences: Preferences
public var mergedPreferences: ContactUserPreferences public var mergedPreferences: ContactUserPreferences
@@ -1384,8 +1385,9 @@ public struct Contact: Identifiable, Decodable, NamedChat {
public var id: ChatId { get { "@\(contactId)" } } public var id: ChatId { get { "@\(contactId)" } }
public var apiId: Int64 { get { contactId } } public var apiId: Int64 { get { contactId } }
public var ready: Bool { get { activeConn.connStatus == .ready } } public var ready: Bool { get { activeConn.connStatus == .ready } }
public var active: Bool { get { contactStatus == .active } }
public var sendMsgEnabled: Bool { get { public var sendMsgEnabled: Bool { get {
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false)) (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?? false))
|| nextSendGrpInv || nextSendGrpInv
} } } }
public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } } public var nextSendGrpInv: Bool { get { contactGroupMemberId != nil && !contactGrpInvSent } }
@@ -1430,6 +1432,7 @@ public struct Contact: Identifiable, Decodable, NamedChat {
profile: LocalProfile.sampleData, profile: LocalProfile.sampleData,
activeConn: Connection.sampleData, activeConn: Connection.sampleData,
contactUsed: true, contactUsed: true,
contactStatus: .active,
chatSettings: ChatSettings.defaults, chatSettings: ChatSettings.defaults,
userPreferences: Preferences.sampleData, userPreferences: Preferences.sampleData,
mergedPreferences: ContactUserPreferences.sampleData, mergedPreferences: ContactUserPreferences.sampleData,
@@ -1439,6 +1442,11 @@ public struct Contact: Identifiable, Decodable, NamedChat {
) )
} }
public enum ContactStatus: String, Decodable {
case active = "active"
case deleted = "deleted"
}
public struct ContactRef: Decodable, Equatable { public struct ContactRef: Decodable, Equatable {
var contactId: Int64 var contactId: Int64
public var agentConnId: String public var agentConnId: String
@@ -1721,6 +1729,11 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat {
) )
} }
public struct GroupRef: Decodable {
public var groupId: Int64
var localDisplayName: GroupName
}
public struct GroupProfile: Codable, NamedChat { public struct GroupProfile: Codable, NamedChat {
public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) { public init(displayName: String, fullName: String, description: String? = nil, image: String? = nil, groupPreferences: GroupPreferences? = nil) {
self.displayName = displayName self.displayName = displayName
@@ -1750,6 +1763,7 @@ public struct GroupMember: Identifiable, Decodable {
public var memberRole: GroupMemberRole public var memberRole: GroupMemberRole
public var memberCategory: GroupMemberCategory public var memberCategory: GroupMemberCategory
public var memberStatus: GroupMemberStatus public var memberStatus: GroupMemberStatus
public var memberSettings: GroupMemberSettings
public var invitedBy: InvitedBy public var invitedBy: InvitedBy
public var localDisplayName: ContactName public var localDisplayName: ContactName
public var memberProfile: LocalProfile public var memberProfile: LocalProfile
@@ -1843,6 +1857,7 @@ public struct GroupMember: Identifiable, Decodable {
memberRole: .admin, memberRole: .admin,
memberCategory: .inviteeMember, memberCategory: .inviteeMember,
memberStatus: .memComplete, memberStatus: .memComplete,
memberSettings: GroupMemberSettings(showMessages: true),
invitedBy: .user, invitedBy: .user,
localDisplayName: "alice", localDisplayName: "alice",
memberProfile: LocalProfile.sampleData, memberProfile: LocalProfile.sampleData,
@@ -1852,11 +1867,20 @@ public struct GroupMember: Identifiable, Decodable {
) )
} }
public struct GroupMemberSettings: Codable {
public var showMessages: Bool
}
public struct GroupMemberRef: Decodable { public struct GroupMemberRef: Decodable {
var groupMemberId: Int64 var groupMemberId: Int64
var profile: Profile var profile: Profile
} }
public struct GroupMemberIds: Decodable {
var groupMemberId: Int64
var groupId: Int64
}
public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable { public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Decodable {
case observer = "observer" case observer = "observer"
case member = "member" case member = "member"
@@ -1949,7 +1973,7 @@ public enum InvitedBy: Decodable {
} }
public struct MemberSubError: Decodable { public struct MemberSubError: Decodable {
var member: GroupMember var member: GroupMemberIds
var memberError: ChatError var memberError: ChatError
} }
@@ -1975,8 +1999,8 @@ public enum ConnectionEntity: Decodable {
public var ntfsEnabled: Bool { public var ntfsEnabled: Bool {
switch self { switch self {
case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs ?? false case let .rcvDirectMsgConnection(contact): return contact?.chatSettings.enableNtfs == .all
case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs case let .rcvGroupMsgConnection(groupInfo, _): return groupInfo.chatSettings.enableNtfs == .all
case .sndFileConnection: return false case .sndFileConnection: return false
case .rcvFileConnection: return false case .rcvFileConnection: return false
case let .userContactConnection(userContact): return userContact.groupId == nil case let .userContactConnection(userContact): return userContact.groupId == nil
@@ -2066,7 +2090,7 @@ public struct ChatItem: Identifiable, Decodable {
public var memberConnected: GroupMember? { public var memberConnected: GroupMember? {
switch chatDir { switch chatDir {
case .groupRcv(let groupMember): case let .groupRcv(groupMember):
switch content { switch content {
case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember case .rcvGroupEvent(rcvGroupEvent: .memberConnected): return groupMember
default: return nil default: return nil
@@ -2075,6 +2099,35 @@ public struct ChatItem: Identifiable, Decodable {
} }
} }
public var mergeCategory: CIMergeCategory? {
switch content {
case .rcvChatFeature: .chatFeature
case .sndChatFeature: .chatFeature
case .rcvGroupFeature: .chatFeature
case .sndGroupFeature: .chatFeature
case let.rcvGroupEvent(event):
switch event {
case .userRole: nil
case .userDeleted: nil
case .groupDeleted: nil
case .memberCreatedContact: nil
default: .rcvGroupEvent
}
case let .sndGroupEvent(event):
switch event {
case .userRole: nil
case .userLeft: nil
default: .sndGroupEvent
}
default:
if meta.itemDeleted == nil {
nil
} else {
chatDir.sent ? .sndItemDeleted : .rcvItemDeleted
}
}
}
private var showNtfDir: Bool { private var showNtfDir: Bool {
return !chatDir.sent return !chatDir.sent
} }
@@ -2091,6 +2144,7 @@ public struct ChatItem: Identifiable, Decodable {
case .rcvDecryptionError: return showNtfDir case .rcvDecryptionError: return showNtfDir
case .rcvGroupInvitation: return showNtfDir case .rcvGroupInvitation: return showNtfDir
case .sndGroupInvitation: return showNtfDir case .sndGroupInvitation: return showNtfDir
case .rcvDirectEvent: return false
case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent): case .rcvGroupEvent(rcvGroupEvent: let rcvGroupEvent):
switch rcvGroupEvent { switch rcvGroupEvent {
case .groupUpdated: return false case .groupUpdated: return false
@@ -2151,7 +2205,7 @@ public struct ChatItem: Identifiable, Decodable {
public var memberDisplayName: String? { public var memberDisplayName: String? {
get { get {
if case let .groupRcv(groupMember) = chatDir { if case let .groupRcv(groupMember) = chatDir {
return groupMember.displayName return groupMember.chatViewName
} else { } else {
return nil return nil
} }
@@ -2305,6 +2359,15 @@ public struct ChatItem: Identifiable, Decodable {
} }
} }
public enum CIMergeCategory {
case memberConnected
case rcvGroupEvent
case sndGroupEvent
case sndItemDeleted
case rcvItemDeleted
case chatFeature
}
public enum CIDirection: Decodable { public enum CIDirection: Decodable {
case directSnd case directSnd
case directRcv case directRcv
@@ -2483,11 +2546,13 @@ public enum SndCIStatusProgress: String, Decodable {
public enum CIDeleted: Decodable { public enum CIDeleted: Decodable {
case deleted(deletedTs: Date?) case deleted(deletedTs: Date?)
case blocked(deletedTs: Date?)
case moderated(deletedTs: Date?, byGroupMember: GroupMember) case moderated(deletedTs: Date?, byGroupMember: GroupMember)
var id: String { var id: String {
switch self { switch self {
case .deleted: return "deleted" case .deleted: return "deleted"
case .blocked: return "blocked"
case .moderated: return "moderated" case .moderated: return "moderated"
} }
} }
@@ -2505,14 +2570,15 @@ protocol ItemContent {
public enum CIContent: Decodable, ItemContent { public enum CIContent: Decodable, ItemContent {
case sndMsgContent(msgContent: MsgContent) case sndMsgContent(msgContent: MsgContent)
case rcvMsgContent(msgContent: MsgContent) case rcvMsgContent(msgContent: MsgContent)
case sndDeleted(deleteMode: CIDeleteMode) case sndDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used
case rcvDeleted(deleteMode: CIDeleteMode) case rcvDeleted(deleteMode: CIDeleteMode) // legacy - since v4.3.0 itemDeleted field is used
case sndCall(status: CICallStatus, duration: Int) case sndCall(status: CICallStatus, duration: Int)
case rcvCall(status: CICallStatus, duration: Int) case rcvCall(status: CICallStatus, duration: Int)
case rcvIntegrityError(msgError: MsgErrorType) case rcvIntegrityError(msgError: MsgErrorType)
case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32) case rcvDecryptionError(msgDecryptError: MsgDecryptError, msgCount: UInt32)
case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole)
case rcvDirectEvent(rcvDirectEvent: RcvDirectEvent)
case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent) case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent)
case sndGroupEvent(sndGroupEvent: SndGroupEvent) case sndGroupEvent(sndGroupEvent: SndGroupEvent)
case rcvConnEvent(rcvConnEvent: RcvConnEvent) case rcvConnEvent(rcvConnEvent: RcvConnEvent)
@@ -2542,6 +2608,7 @@ public enum CIContent: Decodable, ItemContent {
case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text case let .rcvDecryptionError(msgDecryptError, _): return msgDecryptError.text
case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text
case let .rcvDirectEvent(rcvDirectEvent): return rcvDirectEvent.text
case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text
case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text
case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text case let .rcvConnEvent(rcvConnEvent): return rcvConnEvent.text
@@ -3041,7 +3108,7 @@ public enum Format: Decodable, Equatable {
case secret case secret
case colored(color: FormatColor) case colored(color: FormatColor)
case uri case uri
case simplexLink(linkType: SimplexLinkType, simplexUri: String, trustedUri: Bool, smpHosts: [String]) case simplexLink(linkType: SimplexLinkType, simplexUri: String, smpHosts: [String])
case email case email
case phone case phone
} }
@@ -3195,6 +3262,16 @@ public enum CIGroupInvitationStatus: String, Decodable {
case expired case expired
} }
public enum RcvDirectEvent: Decodable {
case contactDeleted
var text: String {
switch self {
case .contactDeleted: return NSLocalizedString("deleted contact", comment: "rcv direct event chat item")
}
}
}
public enum RcvGroupEvent: Decodable { public enum RcvGroupEvent: Decodable {
case memberAdded(groupMemberId: Int64, profile: Profile) case memberAdded(groupMemberId: Int64, profile: Profile)
case memberConnected case memberConnected
+2
View File
@@ -17,12 +17,14 @@ typedef void* chat_ctrl;
// the last parameter is used to return the pointer to chat controller // the last parameter is used to return the pointer to chat controller
extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl); extern char *chat_migrate_init(char *path, char *key, char *confirm, chat_ctrl *ctrl);
extern char *chat_close_store(chat_ctrl ctl);
extern char *chat_send_cmd(chat_ctrl ctl, char *cmd); extern char *chat_send_cmd(chat_ctrl ctl, char *cmd);
extern char *chat_recv_msg(chat_ctrl ctl); extern char *chat_recv_msg(chat_ctrl ctl);
extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait); extern char *chat_recv_msg_wait(chat_ctrl ctl, int wait);
extern char *chat_parse_markdown(char *str); extern char *chat_parse_markdown(char *str);
extern char *chat_parse_server(char *str); extern char *chat_parse_server(char *str);
extern char *chat_password_hash(char *pwd, char *salt); extern char *chat_password_hash(char *pwd, char *salt);
extern char *chat_valid_name(char *name);
extern char *chat_encrypt_media(char *key, char *frame, int len); extern char *chat_encrypt_media(char *key, char *frame, int len);
extern char *chat_decrypt_media(char *key, char *frame, int len); extern char *chat_decrypt_media(char *key, char *frame, int len);
-1
View File
@@ -11,7 +11,6 @@
local.properties local.properties
common/src/commonMain/cpp/android/libs/ common/src/commonMain/cpp/android/libs/
common/src/commonMain/cpp/desktop/libs/ common/src/commonMain/cpp/desktop/libs/
desktop/src/jvmMain/resources/libs/
android/build android/build
android/release android/release
common/build common/build
@@ -77,6 +77,7 @@ android {
} }
jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0 jniLibs.useLegacyPackaging = rootProject.extra["compression.level"] as Int != 0
} }
android.sourceSets["main"].assets.setSrcDirs(listOf("../common/src/commonMain/resources/assets"))
val isRelease = gradle.startParameter.taskNames.find { it.toLowerCase().contains("release") } != null val isRelease = gradle.startParameter.taskNames.find { it.toLowerCase().contains("release") } != null
val isBundle = gradle.startParameter.taskNames.find { it.toLowerCase().contains("bundle") } != null val isBundle = gradle.startParameter.taskNames.find { it.toLowerCase().contains("bundle") } != null
// if (isRelease) { // if (isRelease) {
@@ -91,11 +91,11 @@ class MainActivity: FragmentActivity() {
// When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch // When pressed Back and there is no one wants to process the back event, clear auth state to force re-auth on launch
AppLock.clearAuthState() AppLock.clearAuthState()
AppLock.laFailed.value = true AppLock.laFailed.value = true
AppLock.destroyedAfterBackPress.value = true
} }
if (!onBackPressedDispatcher.hasEnabledCallbacks()) { if (!onBackPressedDispatcher.hasEnabledCallbacks()) {
// Drop shared content // Drop shared content
SimplexApp.context.chatModel.sharedContent.value = null SimplexApp.context.chatModel.sharedContent.value = null
finish()
} }
} }
} }
@@ -143,6 +143,7 @@ fun processExternalIntent(intent: Intent?) {
val text = intent.getStringExtra(Intent.EXTRA_TEXT) val text = intent.getStringExtra(Intent.EXTRA_TEXT)
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
if (uri != null) { if (uri != null) {
if (uri.scheme != "content") return showNonContentUriAlert()
// Shared file that contains plain text, like `*.log` file // Shared file that contains plain text, like `*.log` file
chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI()) chatModel.sharedContent.value = SharedContent.File(text ?: "", uri.toURI())
} else if (text != null) { } else if (text != null) {
@@ -153,12 +154,14 @@ fun processExternalIntent(intent: Intent?) {
isMediaIntent(intent) -> { isMediaIntent(intent) -> {
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
if (uri != null) { if (uri != null) {
if (uri.scheme != "content") return showNonContentUriAlert()
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI())) chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", listOf(uri.toURI()))
} // All other mime types } // All other mime types
} }
else -> { else -> {
val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri val uri = intent.getParcelableExtra<Parcelable>(Intent.EXTRA_STREAM) as? Uri
if (uri != null) { if (uri != null) {
if (uri.scheme != "content") return showNonContentUriAlert()
chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI()) chatModel.sharedContent.value = SharedContent.File(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uri.toURI())
} }
} }
@@ -173,6 +176,7 @@ fun processExternalIntent(intent: Intent?) {
isMediaIntent(intent) -> { isMediaIntent(intent) -> {
val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri> val uris = intent.getParcelableArrayListExtra<Parcelable>(Intent.EXTRA_STREAM) as? List<Uri>
if (uris != null) { if (uris != null) {
if (uris.any { it.scheme != "content" }) return showNonContentUriAlert()
chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() }) chatModel.sharedContent.value = SharedContent.Media(intent.getStringExtra(Intent.EXTRA_TEXT) ?: "", uris.map { it.toURI() })
} // All other mime types } // All other mime types
} }
@@ -185,6 +189,13 @@ fun processExternalIntent(intent: Intent?) {
fun isMediaIntent(intent: Intent): Boolean = fun isMediaIntent(intent: Intent): Boolean =
intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true intent.type?.startsWith("image/") == true || intent.type?.startsWith("video/") == true
private fun showNonContentUriAlert() {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.non_content_uri_alert_title),
text = generalGetString(MR.strings.non_content_uri_alert_text)
)
}
//fun testJson() { //fun testJson() {
// val str: String = """ // val str: String = """
// """.trimIndent() // """.trimIndent()
@@ -9,12 +9,14 @@ import chat.simplex.common.helpers.APPLICATION_ID
import chat.simplex.common.helpers.requiresIgnoringBattery import chat.simplex.common.helpers.requiresIgnoringBattery
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.call.RcvCallInvitation import chat.simplex.common.views.call.RcvCallInvitation
import com.jakewharton.processphoenix.ProcessPhoenix import com.jakewharton.processphoenix.ProcessPhoenix
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.withLock
import java.io.* import java.io.*
import java.util.* import java.util.*
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -52,21 +54,23 @@ class SimplexApp: Application(), LifecycleEventObserver {
Lifecycle.Event.ON_START -> { Lifecycle.Event.ON_START -> {
isAppOnForeground = true isAppOnForeground = true
if (chatModel.chatRunning.value == true) { if (chatModel.chatRunning.value == true) {
kotlin.runCatching { updatingChatsMutex.withLock {
val currentUserId = chatModel.currentUser.value?.userId kotlin.runCatching {
val chats = ArrayList(chatController.apiGetChats()) val currentUserId = chatModel.currentUser.value?.userId
/** Active user can be changed in background while [ChatController.apiGetChats] is executing */ val chats = ArrayList(chatController.apiGetChats())
if (chatModel.currentUser.value?.userId == currentUserId) { /** Active user can be changed in background while [ChatController.apiGetChats] is executing */
val currentChatId = chatModel.chatId.value if (chatModel.currentUser.value?.userId == currentUserId) {
val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null val currentChatId = chatModel.chatId.value
if (oldStats != null) { val oldStats = if (currentChatId != null) chatModel.getChat(currentChatId)?.chatStats else null
val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId } if (oldStats != null) {
/** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */ val indexOfCurrentChat = chats.indexOfFirst { it.id == currentChatId }
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats) /** Pass old chatStats because unreadCounter can be changed already while [ChatController.apiGetChats] is executing */
if (indexOfCurrentChat >= 0) chats[indexOfCurrentChat] = chats[indexOfCurrentChat].copy(chatStats = oldStats)
}
chatModel.updateChats(chats)
} }
chatModel.updateChats(chats) }.onFailure { Log.e(TAG, it.stackTraceToString()) }
} }
}.onFailure { Log.e(TAG, it.stackTraceToString()) }
} }
} }
Lifecycle.Event.ON_RESUME -> { Lifecycle.Event.ON_RESUME -> {
+3 -1
View File
@@ -97,7 +97,9 @@ kotlin {
implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6") implementation("com.github.Dansoftowner:jSystemThemeDetector:3.6")
implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT") implementation("com.sshtools:two-slices:0.9.0-SNAPSHOT")
implementation("org.slf4j:slf4j-simple:2.0.7") implementation("org.slf4j:slf4j-simple:2.0.7")
implementation("uk.co.caprica:vlcj:4.7.0") implementation("uk.co.caprica:vlcj:4.7.3")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd:efb2ebf85a")
implementation("com.github.NanoHttpd.nanohttpd:nanohttpd-websocket:efb2ebf85a")
} }
} }
val desktopTest by getting val desktopTest by getting
@@ -50,6 +50,7 @@ actual fun PlatformTextField(
userIsObserver: Boolean, userIsObserver: Boolean,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit, onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
) { ) {
val cs = composeState.value val cs = composeState.value
@@ -69,3 +69,5 @@ actual fun hideKeyboard(view: Any?) {
(androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0) (androidAppContext.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager).hideSoftInputFromWindow(view.windowToken, 0)
} }
} }
actual fun androidIsFinishingMainActivity(): Boolean = (mainActivity.get()?.isFinishing == true)
@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -43,6 +44,9 @@ import chat.simplex.res.MR
import com.google.accompanist.permissions.rememberMultiplePermissionsState import com.google.accompanist.permissions.rememberMultiplePermissionsState
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.datetime.Clock
import kotlinx.serialization.decodeFromString import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
@@ -52,7 +56,7 @@ actual fun ActiveCallView() {
val chatModel = ChatModel val chatModel = ChatModel
BackHandler(onBack = { BackHandler(onBack = {
val call = chatModel.activeCall.value val call = chatModel.activeCall.value
if (call != null) withApi { chatModel.callManager.endCall(call) } if (call != null) withBGApi { chatModel.callManager.endCall(call) }
}) })
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE } val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
@@ -112,30 +116,30 @@ actual fun ActiveCallView() {
if (call != null) { if (call != null) {
Log.d(TAG, "has active call $call") Log.d(TAG, "has active call $call")
when (val r = apiMsg.resp) { when (val r = apiMsg.resp) {
is WCallResponse.Capabilities -> withApi { is WCallResponse.Capabilities -> withBGApi {
val callType = CallType(call.localMedia, r.capabilities) val callType = CallType(call.localMedia, r.capabilities)
chatModel.controller.apiSendCallInvitation(call.contact, callType) chatModel.controller.apiSendCallInvitation(call.contact, callType)
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
} }
is WCallResponse.Offer -> withApi { is WCallResponse.Offer -> withBGApi {
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities) chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
} }
is WCallResponse.Answer -> withApi { is WCallResponse.Answer -> withBGApi {
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates) chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
} }
is WCallResponse.Ice -> withApi { is WCallResponse.Ice -> withBGApi {
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates) chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
} }
is WCallResponse.Connection -> is WCallResponse.Connection ->
try { try {
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"") val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
if (callStatus == WebRTCCallStatus.Connected) { if (callStatus == WebRTCCallStatus.Connected) {
chatModel.activeCall.value = call.copy(callState = CallState.Connected) chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
setCallSound(call.soundSpeaker, audioViaBluetooth) setCallSound(call.soundSpeaker, audioViaBluetooth)
} }
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) } withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
} catch (e: Error) { } catch (e: Error) {
Log.d(TAG,"call status ${r.state.connectionState} not used") Log.d(TAG,"call status ${r.state.connectionState} not used")
} }
@@ -145,9 +149,12 @@ actual fun ActiveCallView() {
setCallSound(call.soundSpeaker, audioViaBluetooth) setCallSound(call.soundSpeaker, audioViaBluetooth)
} }
} }
is WCallResponse.End -> {
withBGApi { chatModel.callManager.endCall(call) }
}
is WCallResponse.Ended -> { is WCallResponse.Ended -> {
chatModel.activeCall.value = call.copy(callState = CallState.Ended) chatModel.activeCall.value = call.copy(callState = CallState.Ended)
withApi { chatModel.callManager.endCall(call) } withBGApi { chatModel.callManager.endCall(call) }
chatModel.showCallView.value = false chatModel.showCallView.value = false
} }
is WCallResponse.Ok -> when (val cmd = apiMsg.command) { is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
@@ -162,7 +169,7 @@ actual fun ActiveCallView() {
is WCallCommand.Camera -> { is WCallCommand.Camera -> {
chatModel.activeCall.value = call.copy(localCamera = cmd.camera) chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
if (!call.audioEnabled) { if (!call.audioEnabled) {
chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false) chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
} }
} }
is WCallCommand.End -> is WCallCommand.End ->
@@ -187,11 +194,14 @@ actual fun ActiveCallView() {
// Lock orientation to portrait in order to have good experience with calls // Lock orientation to portrait in order to have good experience with calls
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
chatModel.activeCallViewIsVisible.value = true chatModel.activeCallViewIsVisible.value = true
// After the first call, End command gets added to the list which prevents making another calls
chatModel.callCommand.removeAll { it is WCallCommand.End }
onDispose { onDispose {
activity.volumeControlStream = prevVolumeControlStream activity.volumeControlStream = prevVolumeControlStream
// Unlock orientation // Unlock orientation
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
chatModel.activeCallViewIsVisible.value = false chatModel.activeCallViewIsVisible.value = false
chatModel.callCommand.clear()
} }
} }
} }
@@ -201,9 +211,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
ActiveCallOverlayLayout( ActiveCallOverlayLayout(
call = call, call = call,
speakerCanBeEnabled = !audioViaBluetooth.value, speakerCanBeEnabled = !audioViaBluetooth.value,
dismiss = { withApi { chatModel.callManager.endCall(call) } }, dismiss = { withBGApi { chatModel.callManager.endCall(call) } },
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) }, toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) },
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) }, toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) },
toggleSound = { toggleSound = {
var call = chatModel.activeCall.value var call = chatModel.activeCall.value
if (call != null) { if (call != null) {
@@ -212,7 +222,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
setCallSound(call.soundSpeaker, audioViaBluetooth) setCallSound(call.soundSpeaker, audioViaBluetooth)
} }
}, },
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) } flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) }
) )
} }
@@ -439,7 +449,7 @@ private fun DisabledBackgroundCallsButton() {
//} //}
@Composable @Composable
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) { fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val webView = remember { mutableStateOf<WebView?>(null) } val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState( val permissionsState = rememberMultiplePermissionsState(
@@ -470,13 +480,19 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
webView.value = null webView.value = null
} }
} }
LaunchedEffect(callCommand.value, webView.value) { val wv = webView.value
val cmd = callCommand.value if (wv != null) {
val wv = webView.value LaunchedEffect(Unit) {
if (cmd != null && wv != null) { snapshotFlow { callCommand.firstOrNull() }
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd") .distinctUntilChanged()
processCommand(wv, cmd) .filterNotNull()
callCommand.value = null .collect {
while (callCommand.isNotEmpty()) {
val cmd = callCommand.removeFirst()
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
processCommand(wv, cmd)
}
}
} }
} }
val assetLoader = WebViewAssetLoader.Builder() val assetLoader = WebViewAssetLoader.Builder()
@@ -502,7 +518,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
} }
} }
} }
this.webViewClient = LocalContentWebViewClient(assetLoader) this.webViewClient = LocalContentWebViewClient(webView, assetLoader)
this.clearHistory() this.clearHistory()
this.clearCache(true) this.clearCache(true)
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface") this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
@@ -512,19 +528,10 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
webViewSettings.javaScriptEnabled = true webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/call.html") this.loadUrl("file:android_asset/www/android/call.html")
} }
} }
) { wv -> ) { /* WebView */ }
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
scope.launch {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv
}
}
} }
} }
} }
@@ -539,19 +546,28 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
// for debugging // for debugging
// onResponse(message) // onResponse(message)
onResponse(json.decodeFromString(message)) onResponse(json.decodeFromString(message))
} catch (e: Error) { } catch (e: Exception) {
Log.e(TAG, "failed parsing WebView message: $message") Log.e(TAG, "failed parsing WebView message: $message")
} }
} }
} }
private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() { private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
override fun shouldInterceptRequest( override fun shouldInterceptRequest(
view: WebView, view: WebView,
request: WebResourceRequest request: WebResourceRequest
): WebResourceResponse? { ): WebResourceResponse? {
return assetLoader.shouldInterceptRequest(request.url) return assetLoader.shouldInterceptRequest(request.url)
} }
override fun onPageFinished(view: WebView, url: String) {
super.onPageFinished(view, url)
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = view
Log.d(TAG, "WebRTCView: webview ready")
// for debugging
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
}
} }
@Preview @Preview
@@ -5,6 +5,8 @@ import android.os.Build
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.platform.ClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatItem import chat.simplex.common.model.ChatItem
@@ -41,3 +43,7 @@ actual fun SaveContentItemAction(cItem: ChatItem, saveFileLauncher: FileChooserL
showMenu.value = false showMenu.value = false
}) })
} }
actual fun copyItemToClipboard(cItem: ChatItem, clipboard: ClipboardManager) {
clipboard.setText(AnnotatedString(cItem.content.text))
}
@@ -0,0 +1,39 @@
package chat.simplex.common.views.chatlist
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.Divider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
@Composable
actual fun ChatListNavLinkLayout(
chatLinkPreview: @Composable () -> Unit,
click: () -> Unit,
dropdownMenuItems: (@Composable () -> Unit)?,
showMenu: MutableState<Boolean>,
stopped: Boolean,
selectedChat: State<Boolean>
) {
var modifier = Modifier.fillMaxWidth()
if (!stopped) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
Box(modifier) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, top = 8.dp, end = 12.dp, bottom = 8.dp),
verticalAlignment = Alignment.Top
) {
chatLinkPreview()
}
if (dropdownMenuItems != null) {
DefaultDropdownMenu(showMenu, dropdownMenuItems = dropdownMenuItems)
}
}
Divider(Modifier.padding(horizontal = 8.dp))
}
@@ -0,0 +1,8 @@
package chat.simplex.common.views.chatlist
import androidx.compose.runtime.*
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {}
@@ -46,6 +46,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str); extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str); extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_write_file(const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path); extern char *chat_encrypt_file(const char *from_path, const char *to_path);
@@ -60,7 +61,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, __unused j
jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); jstring res = (*env)->NewStringUTF(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm); (*env)->ReleaseStringUTFChars(env, confirm, _confirm);
// Creating array of Object's (boxed values can be passed, eg. Long instead of long) // Creating array of Object's (boxed values can be passed, eg. Long instead of long)
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
@@ -121,6 +122,14 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, __unused
return res; return res;
} }
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) {
const char *_name = (*env)->GetStringUTFChars(env, name, JNI_FALSE);
jstring res = (*env)->NewStringUTF(env, chat_valid_name(_name));
(*env)->ReleaseStringUTFChars(env, name, _name);
return res;
}
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE); const char *_path = (*env)->GetStringUTFChars(env, path, JNI_FALSE);
@@ -31,9 +31,9 @@ else()
set(CMAKE_BUILD_RPATH "@loader_path") set(CMAKE_BUILD_RPATH "@loader_path")
endif() endif()
if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64") if(${CMAKE_SYSTEM_PROCESSOR} MATCHES "amd64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "AMD64")
set(OS_LIB_ARCH "x86_64") set(OS_LIB_ARCH "x86_64")
elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64") elseif(${CMAKE_SYSTEM_PROCESSOR} MATCHES "arm64" OR ${CMAKE_SYSTEM_PROCESSOR} MATCHES "ARM64")
set(OS_LIB_ARCH "aarch64") set(OS_LIB_ARCH "aarch64")
else() else()
set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}") set(OS_LIB_ARCH "${CMAKE_SYSTEM_PROCESSOR}")
@@ -54,9 +54,13 @@ add_library( # Sets the name of the library.
simplex-api.c) simplex-api.c)
add_library( simplex SHARED IMPORTED ) add_library( simplex SHARED IMPORTED )
# Lib has different name because of version, find it if(WIN32)
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSsimplex-chat-*.${OS_LIB_EXT}) FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex*.${OS_LIB_EXT})
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB}) set_target_properties( simplex PROPERTIES IMPORTED_IMPLIB ${SIMPLEXLIB})
else()
FILE(GLOB SIMPLEXLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/lib*simplex-chat*.${OS_LIB_EXT})
set_target_properties( simplex PROPERTIES IMPORTED_LOCATION ${SIMPLEXLIB})
endif()
# Specifies libraries CMake should link to your target library. You # Specifies libraries CMake should link to your target library. You
@@ -67,12 +71,17 @@ if(NOT APPLE)
else() else()
# Without direct linking it can't find hs_init in linking step # Without direct linking it can't find hs_init in linking step
add_library( rts SHARED IMPORTED ) add_library( rts SHARED IMPORTED )
FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/deps/libHSrts*_thr-*.${OS_LIB_EXT}) FILE(GLOB RTSLIB ${CMAKE_SOURCE_DIR}/libs/${OS_LIB_PATH}-${OS_LIB_ARCH}/libHSrts*_thr-*.${OS_LIB_EXT})
set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB}) set_target_properties( rts PROPERTIES IMPORTED_LOCATION ${RTSLIB})
target_link_libraries(app-lib rts simplex) target_link_libraries(app-lib rts simplex)
endif() endif()
if(APPLE)
add_custom_command(TARGET app-lib POST_BUILD
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/patch-libapp-mac.sh
)
endif()
# Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to # Trying to copy resulting files into needed directory, but none of these work for some reason. This could allow to
@@ -0,0 +1,8 @@
#!/bin/bash
set -e
lib=libapp-lib.dylib
RPATHS=$(otool -l $lib | grep -E '/Users|/opt/|/usr/local' | cut -d' ' -f11)
for RPATH in $RPATHS; do
install_name_tool -delete_rpath $RPATH $lib
done
@@ -21,6 +21,7 @@ extern char *chat_recv_msg_wait(chat_ctrl ctrl, const int wait);
extern char *chat_parse_markdown(const char *str); extern char *chat_parse_markdown(const char *str);
extern char *chat_parse_server(const char *str); extern char *chat_parse_server(const char *str);
extern char *chat_password_hash(const char *pwd, const char *salt); extern char *chat_password_hash(const char *pwd, const char *salt);
extern char *chat_valid_name(const char *name);
extern char *chat_write_file(const char *path, char *ptr, int length); extern char *chat_write_file(const char *path, char *ptr, int length);
extern char *chat_read_file(const char *path, const char *key, const char *nonce); extern char *chat_read_file(const char *path, const char *key, const char *nonce);
extern char *chat_encrypt_file(const char *from_path, const char *to_path); extern char *chat_encrypt_file(const char *from_path, const char *to_path);
@@ -75,7 +76,7 @@ Java_chat_simplex_common_platform_CoreKt_chatMigrateInit(JNIEnv *env, jclass cla
jstring res = decode_to_utf8_string(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl)); jstring res = decode_to_utf8_string(env, chat_migrate_init(_dbPath, _dbKey, _confirm, &_ctrl));
(*env)->ReleaseStringUTFChars(env, dbPath, _dbPath); (*env)->ReleaseStringUTFChars(env, dbPath, _dbPath);
(*env)->ReleaseStringUTFChars(env, dbKey, _dbKey); (*env)->ReleaseStringUTFChars(env, dbKey, _dbKey);
(*env)->ReleaseStringUTFChars(env, dbKey, _confirm); (*env)->ReleaseStringUTFChars(env, confirm, _confirm);
// Creating array of Object's (boxed values can be passed, eg. Long instead of long) // Creating array of Object's (boxed values can be passed, eg. Long instead of long)
jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL); jobjectArray ret = (jobjectArray)(*env)->NewObjectArray(env, 2, (*env)->FindClass(env, "java/lang/Object"), NULL);
@@ -133,6 +134,14 @@ Java_chat_simplex_common_platform_CoreKt_chatPasswordHash(JNIEnv *env, jclass cl
return res; return res;
} }
JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatValidName(JNIEnv *env, jclass clazz, jstring name) {
const char *_name = encode_to_utf8_chars(env, name);
jstring res = decode_to_utf8_string(env, chat_valid_name(_name));
(*env)->ReleaseStringUTFChars(env, name, _name);
return res;
}
JNIEXPORT jstring JNICALL JNIEXPORT jstring JNICALL
Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) { Java_chat_simplex_common_platform_CoreKt_chatWriteFile(JNIEnv *env, jclass clazz, jstring path, jobject buffer) {
const char *_path = encode_to_utf8_chars(env, path); const char *_path = encode_to_utf8_chars(env, path);
@@ -17,6 +17,7 @@ import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateFirstProfile
import chat.simplex.common.views.helpers.SimpleButton import chat.simplex.common.views.helpers.SimpleButton
import chat.simplex.common.views.SplashView import chat.simplex.common.views.SplashView
import chat.simplex.common.views.call.ActiveCallView import chat.simplex.common.views.call.ActiveCallView
@@ -135,7 +136,7 @@ fun MainScreen() {
ModalManager.fullscreen.showInView() ModalManager.fullscreen.showInView()
} }
} }
onboarding == OnboardingStage.Step2_CreateProfile -> CreateProfile(chatModel) {} onboarding == OnboardingStage.Step2_CreateProfile -> CreateFirstProfile(chatModel) {}
onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel) onboarding == OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel) onboarding == OnboardingStage.Step3_CreateSimpleXAddress -> CreateSimpleXAddress(chatModel)
onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel) onboarding == OnboardingStage.Step4_SetNotificationsMode -> SetNotificationsMode(chatModel)
@@ -149,7 +150,7 @@ fun MainScreen() {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
// With these constrains when user presses back button while on ChatList, activity destroys and shows auth request // With these constrains when user presses back button while on ChatList, activity destroys and shows auth request
// while the screen moves to a launcher. Detect it and prevent showing the auth // while the screen moves to a launcher. Detect it and prevent showing the auth
if (!(AppLock.destroyedAfterBackPress.value && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) { if (!(androidIsFinishingMainActivity() && chatModel.controller.appPrefs.laMode.get() == LAMode.SYSTEM)) {
AppLock.runAuthenticate() AppLock.runAuthenticate()
} }
} }
@@ -24,7 +24,6 @@ object AppLock {
// Remember result and show it after orientation change // Remember result and show it after orientation change
val laFailed = mutableStateOf(false) val laFailed = mutableStateOf(false)
val destroyedAfterBackPress = mutableStateOf(false)
fun clearAuthState() { fun clearAuthState() {
userAuthorized.value = null userAuthorized.value = null
@@ -6,17 +6,17 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.* import androidx.compose.ui.text.font.*
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import chat.simplex.common.model.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.call.* import chat.simplex.common.views.call.*
import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.chat.ComposeState
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource import dev.icerock.moko.resources.ImageResource
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.* import kotlinx.datetime.*
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import kotlinx.serialization.* import kotlinx.serialization.*
@@ -53,6 +53,7 @@ object ChatModel {
// current chat // current chat
val chatId = mutableStateOf<String?>(null) val chatId = mutableStateOf<String?>(null)
val chatItems = mutableStateListOf<ChatItem>() val chatItems = mutableStateListOf<ChatItem>()
val chatItemStatuses = mutableMapOf<Long, CIStatus>()
val groupMembers = mutableStateListOf<GroupMember>() val groupMembers = mutableStateListOf<GroupMember>()
val terminalItems = mutableStateListOf<TerminalItem>() val terminalItems = mutableStateListOf<TerminalItem>()
@@ -87,7 +88,7 @@ object ChatModel {
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null) val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
val activeCall = mutableStateOf<Call?>(null) val activeCall = mutableStateOf<Call?>(null)
val activeCallViewIsVisible = mutableStateOf<Boolean>(false) val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
val callCommand = mutableStateOf<WCallCommand?>(null) val callCommand = mutableStateListOf<WCallCommand>()
val showCallView = mutableStateOf(false) val showCallView = mutableStateOf(false)
val switchingCall = mutableStateOf(false) val switchingCall = mutableStateOf(false)
@@ -103,6 +104,8 @@ object ChatModel {
val filesToDelete = mutableSetOf<File>() val filesToDelete = mutableSetOf<File>()
val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) } val simplexLinkMode by lazy { mutableStateOf(ChatController.appPrefs.simplexLinkMode.get()) }
var updatingChatsMutex: Mutex = Mutex()
fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) { fun getUser(userId: Long): User? = if (currentUser.value?.userId == userId) {
currentUser.value currentUser.value
} else { } else {
@@ -133,6 +136,7 @@ object ChatModel {
fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null fun hasChat(id: String): Boolean = chats.toList().firstOrNull { it.id == id } != null
fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id } fun getChat(id: String): Chat? = chats.toList().firstOrNull { it.id == id }
fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getContactChat(contactId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId }
fun getGroupChat(groupId: Long): Chat? = chats.toList().firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId }
private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id } private fun getChatIndex(id: String): Int = chats.toList().indexOfFirst { it.id == id }
fun addChat(chat: Chat) = chats.add(index = 0, chat) fun addChat(chat: Chat) = chats.add(index = 0, chat)
@@ -199,7 +203,7 @@ object ChatModel {
} }
} }
suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) { suspend fun addChatItem(cInfo: ChatInfo, cItem: ChatItem) = updatingChatsMutex.withLock {
// update previews // update previews
val i = getChatIndex(cInfo.id) val i = getChatIndex(cInfo.id)
val chat: Chat val chat: Chat
@@ -222,22 +226,25 @@ object ChatModel {
} else { } else {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
} }
// add to current chat Log.d(TAG, "TODOCHAT: addChatItem: adding to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
if (chatId.value == cInfo.id) { withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { // add to current chat
if (chatId.value == cInfo.id) {
Log.d(TAG, "TODOCHAT: addChatItem: chatIds are equal, size ${chatItems.size}")
// Prevent situation when chat item already in the list received from backend // Prevent situation when chat item already in the list received from backend
if (chatItems.none { it.id == cItem.id }) { if (chatItems.none { it.id == cItem.id }) {
if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { if (chatItems.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) {
chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem) chatItems.add(kotlin.math.max(0, chatItems.lastIndex), cItem)
} else { } else {
chatItems.add(cItem) chatItems.add(cItem)
Log.d(TAG, "TODOCHAT: addChatItem: added to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
} }
} }
} }
} }
} }
suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean { suspend fun upsertChatItem(cInfo: ChatInfo, cItem: ChatItem): Boolean = updatingChatsMutex.withLock {
// update previews // update previews
val i = getChatIndex(cInfo.id) val i = getChatIndex(cInfo.id)
val chat: Chat val chat: Chat
@@ -257,30 +264,41 @@ object ChatModel {
addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem))) addChat(Chat(chatInfo = cInfo, chatItems = arrayListOf(cItem)))
res = true res = true
} }
// update current chat Log.d(TAG, "TODOCHAT: upsertChatItem: upserting to chat ${chatId.value} from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
return if (chatId.value == cInfo.id) { return withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { // update current chat
if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) { if (itemIndex >= 0) {
chatItems[itemIndex] = cItem chatItems[itemIndex] = cItem
Log.d(TAG, "TODOCHAT: upsertChatItem: updated in chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
false false
} else { } else {
chatItems.add(cItem) val status = chatItemStatuses.remove(cItem.id)
val ci = if (status != null && cItem.meta.itemStatus is CIStatus.SndNew) {
cItem.copy(meta = cItem.meta.copy(itemStatus = status))
} else {
cItem
}
chatItems.add(ci)
Log.d(TAG, "TODOCHAT: upsertChatItem: added to chat $chatId from ${cInfo.id} ${cItem.id}, size ${chatItems.size}")
true true
} }
} else {
res
} }
} else {
res
} }
} }
suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem) { suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null) {
if (chatId.value == cInfo.id) { withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { if (chatId.value == cInfo.id) {
val itemIndex = chatItems.indexOfFirst { it.id == cItem.id } val itemIndex = chatItems.indexOfFirst { it.id == cItem.id }
if (itemIndex >= 0) { if (itemIndex >= 0) {
chatItems[itemIndex] = cItem chatItems[itemIndex] = cItem
} }
} else if (status != null) {
chatItemStatuses[cItem.id] = status
} }
} }
} }
@@ -318,6 +336,7 @@ object ChatModel {
} }
// clear current chat // clear current chat
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
chatItemStatuses.clear()
chatItems.clear() chatItems.clear()
} }
} }
@@ -374,6 +393,7 @@ object ChatModel {
var markedRead = 0 var markedRead = 0
if (chatId.value == cInfo.id) { if (chatId.value == cInfo.id) {
var i = 0 var i = 0
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marking read ${cInfo.id}, current chatId ${chatId.value}, size was ${chatItems.size}")
while (i < chatItems.count()) { while (i < chatItems.count()) {
val item = chatItems[i] val item = chatItems[i]
if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) { if (item.meta.itemStatus is CIStatus.RcvNew && (range == null || (range.from <= item.id && item.id <= range.to))) {
@@ -388,6 +408,7 @@ object ChatModel {
} }
i += 1 i += 1
} }
Log.d(TAG, "TODOCHAT: markItemsReadInCurrentChat: marked read ${cInfo.id}, current chatId ${chatId.value}, size now ${chatItems.size}")
} }
return markedRead return markedRead
} }
@@ -719,7 +740,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val apiId get() = contactConnection.apiId override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready override val ready get() = contactConnection.ready
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = contactConnection.incognito override val ntfsEnabled get() = false
override val incognito get() = contactConnection.incognito override val incognito get() = contactConnection.incognito
override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature) override fun featureEnabled(feature: ChatFeature) = contactConnection.featureEnabled(feature)
override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL override val timedMessagesTTL: Int? get() = contactConnection.timedMessagesTTL
@@ -779,16 +800,19 @@ sealed class NetworkStatus {
val statusExplanation: String get() = val statusExplanation: String get() =
when (this) { when (this) {
is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact) is Connected -> generalGetString(MR.strings.connected_to_server_to_receive_messages_from_contact)
is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), error) is Error -> String.format(generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages_with_error), connectionError)
else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages) else -> generalGetString(MR.strings.trying_to_connect_to_server_to_receive_messages)
} }
@Serializable @SerialName("unknown") class Unknown: NetworkStatus() @Serializable @SerialName("unknown") class Unknown: NetworkStatus()
@Serializable @SerialName("connected") class Connected: NetworkStatus() @Serializable @SerialName("connected") class Connected: NetworkStatus()
@Serializable @SerialName("disconnected") class Disconnected: NetworkStatus() @Serializable @SerialName("disconnected") class Disconnected: NetworkStatus()
@Serializable @SerialName("error") class Error(val error: String): NetworkStatus() @Serializable @SerialName("error") class Error(val connectionError: String): NetworkStatus()
} }
@Serializable
data class ConnNetworkStatus(val agentConnId: String, val networkStatus: NetworkStatus)
@Serializable @Serializable
data class Contact( data class Contact(
val contactId: Long, val contactId: Long,
@@ -797,6 +821,7 @@ data class Contact(
val activeConn: Connection, val activeConn: Connection,
val viaGroup: Long? = null, val viaGroup: Long? = null,
val contactUsed: Boolean, val contactUsed: Boolean,
val contactStatus: ContactStatus,
val chatSettings: ChatSettings, val chatSettings: ChatSettings,
val userPreferences: ChatPreferences, val userPreferences: ChatPreferences,
val mergedPreferences: ContactUserPreferences, val mergedPreferences: ContactUserPreferences,
@@ -809,11 +834,12 @@ data class Contact(
override val id get() = "@$contactId" override val id get() = "@$contactId"
override val apiId get() = contactId override val apiId get() = contactId
override val ready get() = activeConn.connStatus == ConnStatus.Ready override val ready get() = activeConn.connStatus == ConnStatus.Ready
val active get() = contactStatus == ContactStatus.Active
override val sendMsgEnabled get() = override val sendMsgEnabled get() =
(ready && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false)) (ready && active && !(activeConn.connectionStats?.ratchetSyncSendProhibited ?: false))
|| nextSendGrpInv || nextSendGrpInv
val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent val nextSendGrpInv get() = contactGroupMemberId != null && !contactGrpInvSent
override val ntfsEnabled get() = chatSettings.enableNtfs override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = contactConnIncognito override val incognito get() = contactConnIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) { override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser ChatFeature.TimedMessages -> mergedPreferences.timedMessages.enabled.forUser
@@ -859,7 +885,8 @@ data class Contact(
profile = LocalProfile.sampleData, profile = LocalProfile.sampleData,
activeConn = Connection.sampleData, activeConn = Connection.sampleData,
contactUsed = true, contactUsed = true,
chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), contactStatus = ContactStatus.Active,
chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
userPreferences = ChatPreferences.sampleData, userPreferences = ChatPreferences.sampleData,
mergedPreferences = ContactUserPreferences.sampleData, mergedPreferences = ContactUserPreferences.sampleData,
createdAt = Clock.System.now(), createdAt = Clock.System.now(),
@@ -869,6 +896,12 @@ data class Contact(
} }
} }
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
}
@Serializable @Serializable
class ContactRef( class ContactRef(
val contactId: Long, val contactId: Long,
@@ -993,7 +1026,7 @@ data class GroupInfo (
override val apiId get() = groupId override val apiId get() = groupId
override val ready get() = membership.memberActive override val ready get() = membership.memberActive
override val sendMsgEnabled get() = membership.memberActive override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = membership.memberIncognito override val incognito get() = membership.memberIncognito
override fun featureEnabled(feature: ChatFeature) = when (feature) { override fun featureEnabled(feature: ChatFeature) = when (feature) {
ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on ChatFeature.TimedMessages -> fullGroupPreferences.timedMessages.on
@@ -1025,13 +1058,16 @@ data class GroupInfo (
fullGroupPreferences = FullGroupPreferences.sampleData, fullGroupPreferences = FullGroupPreferences.sampleData,
membership = GroupMember.sampleData, membership = GroupMember.sampleData,
hostConnCustomUserProfileId = null, hostConnCustomUserProfileId = null,
chatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false), chatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false),
createdAt = Clock.System.now(), createdAt = Clock.System.now(),
updatedAt = Clock.System.now() updatedAt = Clock.System.now()
) )
} }
} }
@Serializable
data class GroupRef(val groupId: Long, val localDisplayName: String)
@Serializable @Serializable
data class GroupProfile ( data class GroupProfile (
override val displayName: String, override val displayName: String,
@@ -1057,6 +1093,7 @@ data class GroupMember (
var memberRole: GroupMemberRole, var memberRole: GroupMemberRole,
var memberCategory: GroupMemberCategory, var memberCategory: GroupMemberCategory,
var memberStatus: GroupMemberStatus, var memberStatus: GroupMemberStatus,
var memberSettings: GroupMemberSettings,
var invitedBy: InvitedBy, var invitedBy: InvitedBy,
val localDisplayName: String, val localDisplayName: String,
val memberProfile: LocalProfile, val memberProfile: LocalProfile,
@@ -1124,6 +1161,7 @@ data class GroupMember (
memberRole = GroupMemberRole.Member, memberRole = GroupMemberRole.Member,
memberCategory = GroupMemberCategory.InviteeMember, memberCategory = GroupMemberCategory.InviteeMember,
memberStatus = GroupMemberStatus.MemComplete, memberStatus = GroupMemberStatus.MemComplete,
memberSettings = GroupMemberSettings(showMessages = true),
invitedBy = InvitedBy.IBUser(), invitedBy = InvitedBy.IBUser(),
localDisplayName = "alice", localDisplayName = "alice",
memberProfile = LocalProfile.sampleData, memberProfile = LocalProfile.sampleData,
@@ -1135,11 +1173,20 @@ data class GroupMember (
} }
@Serializable @Serializable
class GroupMemberRef( data class GroupMemberSettings(val showMessages: Boolean) {}
@Serializable
data class GroupMemberRef(
val groupMemberId: Long, val groupMemberId: Long,
val profile: Profile val profile: Profile
) )
@Serializable
data class GroupMemberIds(
val groupMemberId: Long,
val groupId: Long
)
@Serializable @Serializable
enum class GroupMemberRole(val memberRole: String) { enum class GroupMemberRole(val memberRole: String) {
@SerialName("observer") Observer("observer"), // order matters in comparisons @SerialName("observer") Observer("observer"), // order matters in comparisons
@@ -1233,7 +1280,7 @@ class LinkPreview (
@Serializable @Serializable
class MemberSubError ( class MemberSubError (
val member: GroupMember, val member: GroupMemberIds,
val memberError: ChatError val memberError: ChatError
) )
@@ -1471,6 +1518,7 @@ data class ChatItem (
is CIContent.RcvDecryptionError -> showNtfDir is CIContent.RcvDecryptionError -> showNtfDir
is CIContent.RcvGroupInvitation -> showNtfDir is CIContent.RcvGroupInvitation -> showNtfDir
is CIContent.SndGroupInvitation -> showNtfDir is CIContent.SndGroupInvitation -> showNtfDir
is CIContent.RcvDirectEventContent -> false
is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) { is CIContent.RcvGroupEventContent -> when (content.rcvGroupEvent) {
is RcvGroupEvent.MemberAdded -> false is RcvGroupEvent.MemberAdded -> false
is RcvGroupEvent.MemberConnected -> false is RcvGroupEvent.MemberConnected -> false
@@ -1827,6 +1875,7 @@ enum class SndCIStatusProgress {
@Serializable @Serializable
sealed class CIDeleted { sealed class CIDeleted {
@Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted() @Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
@Serializable @SerialName("blocked") class Blocked(val deletedTs: Instant?): CIDeleted()
@Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted() @Serializable @SerialName("moderated") class Moderated(val deletedTs: Instant?, val byGroupMember: GroupMember): CIDeleted()
} }
@@ -1854,6 +1903,7 @@ sealed class CIContent: ItemContent {
@Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvDecryptionError") class RcvDecryptionError(val msgDecryptError: MsgDecryptError, val msgCount: UInt): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvDirectEvent") class RcvDirectEventContent(val rcvDirectEvent: RcvDirectEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvConnEvent") class RcvConnEventContent(val rcvConnEvent: RcvConnEvent): CIContent() { override val msgContent: MsgContent? get() = null }
@@ -1881,6 +1931,7 @@ sealed class CIContent: ItemContent {
is RcvDecryptionError -> msgDecryptError.text is RcvDecryptionError -> msgDecryptError.text
is RcvGroupInvitation -> groupInvitation.text is RcvGroupInvitation -> groupInvitation.text
is SndGroupInvitation -> groupInvitation.text is SndGroupInvitation -> groupInvitation.text
is RcvDirectEventContent -> rcvDirectEvent.text
is RcvGroupEventContent -> rcvGroupEvent.text is RcvGroupEventContent -> rcvGroupEvent.text
is SndGroupEventContent -> sndGroupEvent.text is SndGroupEventContent -> sndGroupEvent.text
is RcvConnEventContent -> rcvConnEvent.text is RcvConnEventContent -> rcvConnEvent.text
@@ -2376,7 +2427,7 @@ sealed class Format {
@Serializable @SerialName("secret") class Secret: Format() @Serializable @SerialName("secret") class Secret: Format()
@Serializable @SerialName("colored") class Colored(val color: FormatColor): Format() @Serializable @SerialName("colored") class Colored(val color: FormatColor): Format()
@Serializable @SerialName("uri") class Uri: Format() @Serializable @SerialName("uri") class Uri: Format()
@Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val trustedUri: Boolean, val smpHosts: List<String>): Format() @Serializable @SerialName("simplexLink") class SimplexLink(val linkType: SimplexLinkType, val simplexUri: String, val smpHosts: List<String>): Format()
@Serializable @SerialName("email") class Email: Format() @Serializable @SerialName("email") class Email: Format()
@Serializable @SerialName("phone") class Phone: Format() @Serializable @SerialName("phone") class Phone: Format()
@@ -2487,6 +2538,15 @@ sealed class MsgErrorType() {
} }
} }
@Serializable
sealed class RcvDirectEvent() {
@Serializable @SerialName("contactDeleted") class ContactDeleted(): RcvDirectEvent()
val text: String get() = when (this) {
is ContactDeleted -> generalGetString(MR.strings.rcv_direct_event_contact_deleted)
}
}
@Serializable @Serializable
sealed class RcvGroupEvent() { sealed class RcvGroupEvent() {
@Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent()
@@ -4,6 +4,7 @@ import chat.simplex.common.views.helpers.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import chat.simplex.common.model.ChatModel.updatingChatsMutex
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
@@ -16,6 +17,7 @@ import com.charleskorn.kaml.YamlConfiguration
import chat.simplex.res.MR import chat.simplex.res.MR
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import kotlinx.serialization.* import kotlinx.serialization.*
@@ -106,6 +108,7 @@ class AppPreferences {
val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null) val chatArchiveTime = mkDatePreference(SHARED_PREFS_CHAT_ARCHIVE_TIME, null)
val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null) val chatLastStart = mkDatePreference(SHARED_PREFS_CHAT_LAST_START, null)
val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false) val developerTools = mkBoolPreference(SHARED_PREFS_DEVELOPER_TOOLS, false)
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false) val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050") val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name) private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name)
@@ -265,6 +268,7 @@ class AppPreferences {
private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage" private const val SHARED_PREFS_ONBOARDING_STAGE = "OnboardingStage"
private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart" private const val SHARED_PREFS_CHAT_LAST_START = "ChatLastStart"
private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools" private const val SHARED_PREFS_DEVELOPER_TOOLS = "DeveloperTools"
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy" private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort" private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode" private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"
@@ -334,6 +338,7 @@ object ChatController {
apiSetTempFolder(coreTmpDir.absolutePath) apiSetTempFolder(coreTmpDir.absolutePath)
apiSetFilesFolder(appFilesDir.absolutePath) apiSetFilesFolder(appFilesDir.absolutePath)
apiSetXFTPConfig(getXFTPCfg()) apiSetXFTPConfig(getXFTPCfg())
apiSetEncryptLocalFiles(appPrefs.privacyEncryptLocalFiles.get())
val justStarted = apiStartChat() val justStarted = apiStartChat()
val users = listUsers() val users = listUsers()
chatModel.users.clear() chatModel.users.clear()
@@ -347,8 +352,10 @@ object ChatController {
startReceiver() startReceiver()
Log.d(TAG, "startChat: started") Log.d(TAG, "startChat: started")
} else { } else {
val chats = apiGetChats() updatingChatsMutex.withLock {
chatModel.updateChats(chats) val chats = apiGetChats()
chatModel.updateChats(chats)
}
Log.d(TAG, "startChat: running") Log.d(TAG, "startChat: running")
} }
} catch (e: Error) { } catch (e: Error) {
@@ -382,8 +389,10 @@ object ChatController {
suspend fun getUserChatData() { suspend fun getUserChatData() {
chatModel.userAddress.value = apiGetUserAddress() chatModel.userAddress.value = apiGetUserAddress()
chatModel.chatItemTTL.value = getChatItemTTL() chatModel.chatItemTTL.value = getChatItemTTL()
val chats = apiGetChats() updatingChatsMutex.withLock {
chatModel.updateChats(chats) val chats = apiGetChats()
chatModel.updateChats(chats)
}
} }
private fun startReceiver() { private fun startReceiver() {
@@ -551,6 +560,8 @@ object ChatController {
throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}") throw Error("apiSetXFTPConfig bad response: ${r.responseType} ${r.details}")
} }
suspend fun apiSetEncryptLocalFiles(enable: Boolean) = sendCommandOkResp(CC.ApiSetEncryptLocalFiles(enable))
suspend fun apiExportArchive(config: ArchiveConfig) { suspend fun apiExportArchive(config: ArchiveConfig) {
val r = sendCmd(CC.ApiExportArchive(config)) val r = sendCmd(CC.ApiExportArchive(config))
if (r is CR.CmdOk) return if (r is CR.CmdOk) return
@@ -844,6 +855,14 @@ object ChatController {
return null return null
} }
suspend fun apiConnectPlan(connReq: String): ConnectionPlan? {
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
val r = sendCmd(CC.APIConnectPlan(userId, connReq))
if (r is CR.CRConnectionPlan) return r.connectionPlan
Log.e(TAG, "apiConnectPlan bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean { suspend fun apiConnect(incognito: Boolean, connReq: String): Boolean {
val userId = chatModel.currentUser.value?.userId ?: run { val userId = chatModel.currentUser.value?.userId ?: run {
Log.e(TAG, "apiConnect: no current user") Log.e(TAG, "apiConnect: no current user")
@@ -885,8 +904,8 @@ object ChatController {
} }
} }
suspend fun apiDeleteChat(type: ChatType, id: Long): Boolean { suspend fun apiDeleteChat(type: ChatType, id: Long, notify: Boolean? = null): Boolean {
val r = sendCmd(CC.ApiDeleteChat(type, id)) val r = sendCmd(CC.ApiDeleteChat(type, id, notify))
when { when {
r is CR.ContactDeleted && type == ChatType.Direct -> return true r is CR.ContactDeleted && type == ChatType.Direct -> return true
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> return true
@@ -924,6 +943,9 @@ object ChatController {
val r = sendCmd(CC.ApiUpdateProfile(userId, profile)) val r = sendCmd(CC.ApiUpdateProfile(userId, profile))
if (r is CR.UserProfileNoChange) return profile to emptyList() if (r is CR.UserProfileNoChange) return profile to emptyList()
if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts if (r is CR.UserProfileUpdated) return r.toProfile to r.updateSummary.changedContacts
if (r is CR.ChatCmdError && r.chatError is ChatError.ChatErrorStore && r.chatError.storeError is StoreError.DuplicateName) {
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.failed_to_create_user_duplicate_title), generalGetString(MR.strings.failed_to_create_user_duplicate_desc))
}
Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}") Log.e(TAG, "apiUpdateProfile bad response: ${r.responseType} ${r.details}")
return null return null
} }
@@ -1074,6 +1096,13 @@ object ChatController {
return r is CR.CmdOk return r is CR.CmdOk
} }
suspend fun apiGetNetworkStatuses(): List<ConnNetworkStatus>? {
val r = sendCmd(CC.ApiGetNetworkStatuses())
if (r is CR.NetworkStatuses) return r.networkStatuses
Log.e(TAG, "apiGetNetworkStatuses bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean { suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
val r = sendCmd(CC.ApiChatRead(type, id, range)) val r = sendCmd(CC.ApiChatRead(type, id, range))
if (r is CR.CmdOk) return true if (r is CR.CmdOk) return true
@@ -1137,9 +1166,9 @@ object ChatController {
} }
} }
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? { suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? {
val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null } val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null }
val r = sendCmd(CC.ApiNewGroup(userId, p)) val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile))
if (r is CR.GroupCreated) return r.groupInfo if (r is CR.GroupCreated) return r.groupInfo
Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}") Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
return null return null
@@ -1312,6 +1341,13 @@ object ChatController {
} }
} }
private suspend fun sendCommandOkResp(cmd: CC): Boolean {
val r = sendCmd(cmd)
val ok = r is CR.CmdOk
if (!ok) apiErrorAlert(cmd.cmdType, generalGetString(MR.strings.error_alert_title), r)
return ok
}
suspend fun apiGetVersion(): CoreVersionInfo? { suspend fun apiGetVersion(): CoreVersionInfo? {
val r = sendCmd(CC.ShowVersion()) val r = sendCmd(CC.ShowVersion())
return if (r is CR.VersionInfo) { return if (r is CR.VersionInfo) {
@@ -1366,6 +1402,11 @@ object ChatController {
chatModel.removeChat(r.connection.id) chatModel.removeChat(r.connection.id)
} }
} }
is CR.ContactDeletedByContact -> {
if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(r.contact)
}
}
is CR.ContactConnected -> { is CR.ContactConnected -> {
if (active(r.user) && r.contact.directOrUsed) { if (active(r.user) && r.contact.directOrUsed) {
chatModel.updateContact(r.contact) chatModel.updateContact(r.contact)
@@ -1402,6 +1443,11 @@ object ChatController {
chatModel.updateChatInfo(cInfo) chatModel.updateChatInfo(cInfo)
} }
} }
is CR.GroupMemberUpdated -> {
if (active(r.user)) {
chatModel.upsertGroupMember(r.groupInfo, r.toMember)
}
}
is CR.ContactsMerged -> { is CR.ContactsMerged -> {
if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) { if (active(r.user) && chatModel.hasChat(r.mergedContact.id)) {
if (chatModel.chatId.value == r.mergedContact.id) { if (chatModel.chatId.value == r.mergedContact.id) {
@@ -1412,12 +1458,6 @@ object ChatController {
} }
is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected()) is CR.ContactsSubscribed -> updateContactsStatus(r.contactRefs, NetworkStatus.Connected())
is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected()) is CR.ContactsDisconnected -> updateContactsStatus(r.contactRefs, NetworkStatus.Disconnected())
is CR.ContactSubError -> {
if (active(r.user)) {
chatModel.updateContact(r.contact)
}
processContactSubError(r.contact, r.chatError)
}
is CR.ContactSubSummary -> { is CR.ContactSubSummary -> {
for (sub in r.contactSubscriptions) { for (sub in r.contactSubscriptions) {
if (active(r.user)) { if (active(r.user)) {
@@ -1431,6 +1471,16 @@ object ChatController {
} }
} }
} }
is CR.NetworkStatusResp -> {
for (cId in r.connections) {
chatModel.networkStatuses[cId] = r.networkStatus
}
}
is CR.NetworkStatuses -> {
for (s in r.networkStatuses) {
chatModel.networkStatuses[s.agentConnId] = s.networkStatus
}
}
is CR.NewChatItem -> { is CR.NewChatItem -> {
val cInfo = r.chatItem.chatInfo val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem val cItem = r.chatItem.chatItem
@@ -1455,11 +1505,8 @@ object ChatController {
is CR.ChatItemStatusUpdated -> { is CR.ChatItemStatusUpdated -> {
val cInfo = r.chatItem.chatInfo val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem val cItem = r.chatItem.chatItem
if (!cItem.isDeletedContent) { if (!cItem.isDeletedContent && active(r.user)) {
val added = if (active(r.user)) chatModel.upsertChatItem(cInfo, cItem) else true chatModel.updateChatItem(cInfo, cItem, status = cItem.meta.itemStatus)
if (added && cItem.showNotification) {
ntfManager.notifyMessageReceived(r.user, cInfo, cItem)
}
} }
} }
is CR.ChatItemUpdated -> is CR.ChatItemUpdated ->
@@ -1511,6 +1558,16 @@ object ChatController {
chatModel.removeChat(r.hostContact.activeConn.id) chatModel.removeChat(r.hostContact.activeConn.id)
} }
} }
is CR.GroupLinkConnecting -> {
if (!active(r.user)) return
chatModel.updateGroup(r.groupInfo)
val hostConn = r.hostMember.activeConn
if (hostConn != null) {
chatModel.dismissConnReqView(hostConn.id)
chatModel.removeChat(hostConn.id)
}
}
is CR.JoinedGroupMemberConnecting -> is CR.JoinedGroupMemberConnecting ->
if (active(r.user)) { if (active(r.user)) {
chatModel.upsertGroupMember(r.groupInfo, r.member) chatModel.upsertGroupMember(r.groupInfo, r.member)
@@ -1608,25 +1665,25 @@ object ChatController {
val useRelay = appPrefs.webrtcPolicyRelay.get() val useRelay = appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers() val iceServers = getIceServers()
Log.d(TAG, ".callOffer iceServers $iceServers") Log.d(TAG, ".callOffer iceServers $iceServers")
chatModel.callCommand.value = WCallCommand.Offer( chatModel.callCommand.add(WCallCommand.Offer(
offer = r.offer.rtcSession, offer = r.offer.rtcSession,
iceCandidates = r.offer.rtcIceCandidates, iceCandidates = r.offer.rtcIceCandidates,
media = r.callType.media, media = r.callType.media,
aesKey = r.sharedKey, aesKey = r.sharedKey,
iceServers = iceServers, iceServers = iceServers,
relay = useRelay relay = useRelay
) ))
} }
} }
is CR.CallAnswer -> { is CR.CallAnswer -> {
withCall(r, r.contact) { call -> withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived) chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived)
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates) chatModel.callCommand.add(WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates))
} }
} }
is CR.CallExtraInfo -> { is CR.CallExtraInfo -> {
withCall(r, r.contact) { _ -> withCall(r, r.contact) { _ ->
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates) chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates))
} }
} }
is CR.CallEnded -> { is CR.CallEnded -> {
@@ -1635,7 +1692,7 @@ object ChatController {
chatModel.callManager.reportCallRemoteEnded(invitation = invitation) chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
} }
withCall(r, r.contact) { _ -> withCall(r, r.contact) { _ ->
chatModel.callCommand.value = WCallCommand.End chatModel.callCommand.add(WCallCommand.End)
withApi { withApi {
chatModel.activeCall.value = null chatModel.activeCall.value = null
chatModel.showCallView.value = false chatModel.showCallView.value = false
@@ -1834,6 +1891,7 @@ sealed class CC {
class SetTempFolder(val tempFolder: String): CC() class SetTempFolder(val tempFolder: String): CC()
class SetFilesFolder(val filesFolder: String): CC() class SetFilesFolder(val filesFolder: String): CC()
class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC() class ApiSetXFTPConfig(val config: XFTPFileConfig?): CC()
class ApiSetEncryptLocalFiles(val enable: Boolean): CC()
class ApiExportArchive(val config: ArchiveConfig): CC() class ApiExportArchive(val config: ArchiveConfig): CC()
class ApiImportArchive(val config: ArchiveConfig): CC() class ApiImportArchive(val config: ArchiveConfig): CC()
class ApiDeleteStorage: CC() class ApiDeleteStorage: CC()
@@ -1846,7 +1904,7 @@ sealed class CC {
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC() class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC() class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC() class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC() class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC() class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC() class ApiJoinGroup(val groupId: Long): CC()
class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC() class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
@@ -1882,8 +1940,9 @@ sealed class CC {
class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC() class APIVerifyGroupMember(val groupId: Long, val groupMemberId: Long, val connectionCode: String?): CC()
class APIAddContact(val userId: Long, val incognito: Boolean): CC() class APIAddContact(val userId: Long, val incognito: Boolean): CC()
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC() class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC() class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiDeleteChat(val type: ChatType, val id: Long): CC() class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC() class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC() class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC() class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
@@ -1902,11 +1961,12 @@ sealed class CC {
class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC() class ApiSendCallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CC()
class ApiEndCall(val contact: Contact): CC() class ApiEndCall(val contact: Contact): CC()
class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC() class ApiCallStatus(val contact: Contact, val callStatus: WebRTCCallStatus): CC()
class ApiGetNetworkStatuses(): CC()
class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC() class ApiAcceptContact(val incognito: Boolean, val contactReqId: Long): CC()
class ApiRejectContact(val contactReqId: Long): CC() class ApiRejectContact(val contactReqId: Long): CC()
class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC() class ApiChatRead(val type: ChatType, val id: Long, val range: ItemRange): CC()
class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC() class ApiChatUnread(val type: ChatType, val id: Long, val unreadChat: Boolean): CC()
class ReceiveFile(val fileId: Long, val encrypted: Boolean, val inline: Boolean?): CC() class ReceiveFile(val fileId: Long, val encrypt: Boolean, val inline: Boolean?): CC()
class CancelFile(val fileId: Long): CC() class CancelFile(val fileId: Long): CC()
class ShowVersion(): CC() class ShowVersion(): CC()
@@ -1938,6 +1998,7 @@ sealed class CC {
is SetTempFolder -> "/_temp_folder $tempFolder" is SetTempFolder -> "/_temp_folder $tempFolder"
is SetFilesFolder -> "/_files_folder $filesFolder" is SetFilesFolder -> "/_files_folder $filesFolder"
is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off" is ApiSetXFTPConfig -> if (config != null) "/_xftp on ${json.encodeToString(config)}" else "/_xftp off"
is ApiSetEncryptLocalFiles -> "/_files_encrypt ${onOff(enable)}"
is ApiExportArchive -> "/_db export ${json.encodeToString(config)}" is ApiExportArchive -> "/_db export ${json.encodeToString(config)}"
is ApiImportArchive -> "/_db import ${json.encodeToString(config)}" is ApiImportArchive -> "/_db import ${json.encodeToString(config)}"
is ApiDeleteStorage -> "/_db delete" is ApiDeleteStorage -> "/_db delete"
@@ -1953,7 +2014,7 @@ sealed class CC {
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}" is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId" is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}" is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}" is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}" is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId" is ApiJoinGroup -> "/_join #$groupId"
is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}" is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}"
@@ -1989,8 +2050,13 @@ sealed class CC {
is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else "" is APIVerifyGroupMember -> "/_verify code #$groupId $groupMemberId" + if (connectionCode != null) " $connectionCode" else ""
is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}" is APIAddContact -> "/_connect $userId incognito=${onOff(incognito)}"
is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}" is ApiSetConnectionIncognito -> "/_set incognito :$connId ${onOff(incognito)}"
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq" is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiDeleteChat -> "/_delete ${chatRef(type, id)}" is ApiDeleteChat -> if (notify != null) {
"/_delete ${chatRef(type, id)} notify=${onOff(notify)}"
} else {
"/_delete ${chatRef(type, id)}"
}
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}" is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId" is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}" is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
@@ -2011,9 +2077,13 @@ sealed class CC {
is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}" is ApiSendCallExtraInfo -> "/_call extra @${contact.apiId} ${json.encodeToString(extraInfo)}"
is ApiEndCall -> "/_call end @${contact.apiId}" is ApiEndCall -> "/_call end @${contact.apiId}"
is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}" is ApiCallStatus -> "/_call status @${contact.apiId} ${callStatus.value}"
is ApiGetNetworkStatuses -> "/_network_statuses"
is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}" is ApiChatRead -> "/_read chat ${chatRef(type, id)} from=${range.from} to=${range.to}"
is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}" is ApiChatUnread -> "/_unread chat ${chatRef(type, id)} ${onOff(unreadChat)}"
is ReceiveFile -> "/freceive $fileId encrypt=${onOff(encrypted)}" + (if (inline == null) "" else " inline=${onOff(inline)}") is ReceiveFile ->
"/freceive $fileId" +
(if (encrypt == null) "" else " encrypt=${onOff(encrypt)}") +
(if (inline == null) "" else " inline=${onOff(inline)}")
is CancelFile -> "/fcancel $fileId" is CancelFile -> "/fcancel $fileId"
is ShowVersion -> "/version" is ShowVersion -> "/version"
} }
@@ -2037,6 +2107,7 @@ sealed class CC {
is SetTempFolder -> "setTempFolder" is SetTempFolder -> "setTempFolder"
is SetFilesFolder -> "setFilesFolder" is SetFilesFolder -> "setFilesFolder"
is ApiSetXFTPConfig -> "apiSetXFTPConfig" is ApiSetXFTPConfig -> "apiSetXFTPConfig"
is ApiSetEncryptLocalFiles -> "apiSetEncryptLocalFiles"
is ApiExportArchive -> "apiExportArchive" is ApiExportArchive -> "apiExportArchive"
is ApiImportArchive -> "apiImportArchive" is ApiImportArchive -> "apiImportArchive"
is ApiDeleteStorage -> "apiDeleteStorage" is ApiDeleteStorage -> "apiDeleteStorage"
@@ -2085,6 +2156,7 @@ sealed class CC {
is APIVerifyGroupMember -> "apiVerifyGroupMember" is APIVerifyGroupMember -> "apiVerifyGroupMember"
is APIAddContact -> "apiAddContact" is APIAddContact -> "apiAddContact"
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito" is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
is APIConnectPlan -> "apiConnectPlan"
is APIConnect -> "apiConnect" is APIConnect -> "apiConnect"
is ApiDeleteChat -> "apiDeleteChat" is ApiDeleteChat -> "apiDeleteChat"
is ApiClearChat -> "apiClearChat" is ApiClearChat -> "apiClearChat"
@@ -2107,6 +2179,7 @@ sealed class CC {
is ApiSendCallExtraInfo -> "apiSendCallExtraInfo" is ApiSendCallExtraInfo -> "apiSendCallExtraInfo"
is ApiEndCall -> "apiEndCall" is ApiEndCall -> "apiEndCall"
is ApiCallStatus -> "apiCallStatus" is ApiCallStatus -> "apiCallStatus"
is ApiGetNetworkStatuses -> "apiGetNetworkStatuses"
is ApiChatRead -> "apiChatRead" is ApiChatRead -> "apiChatRead"
is ApiChatUnread -> "apiChatUnread" is ApiChatUnread -> "apiChatUnread"
is ReceiveFile -> "receiveFile" is ReceiveFile -> "receiveFile"
@@ -2465,15 +2538,22 @@ data class KeepAliveOpts(
@Serializable @Serializable
data class ChatSettings( data class ChatSettings(
val enableNtfs: Boolean, val enableNtfs: MsgFilter,
val sendRcpts: Boolean?, val sendRcpts: Boolean?,
val favorite: Boolean val favorite: Boolean
) { ) {
companion object { companion object {
val defaults: ChatSettings = ChatSettings(enableNtfs = true, sendRcpts = null, favorite = false) val defaults: ChatSettings = ChatSettings(enableNtfs = MsgFilter.All, sendRcpts = null, favorite = false)
} }
} }
@Serializable
enum class MsgFilter {
@SerialName("all") All,
@SerialName("none") None,
@SerialName("mentions") Mentions,
}
@Serializable @Serializable
data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean) data class UserMsgReceiptSettings(val enable: Boolean, val clearOverrides: Boolean)
@@ -3290,11 +3370,13 @@ sealed class CR {
@Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR() @Serializable @SerialName("connectionVerified") class ConnectionVerified(val user: UserRef, val verified: Boolean, val expectedCode: String): CR()
@Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR() @Serializable @SerialName("invitation") class Invitation(val user: UserRef, val connReqInvitation: String, val connection: PendingContactConnection): CR()
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR() @Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR() @Serializable @SerialName("sentConfirmation") class SentConfirmation(val user: UserRef): CR()
@Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR() @Serializable @SerialName("sentInvitation") class SentInvitation(val user: UserRef): CR()
@Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactRequestAlreadyAccepted") class ContactRequestAlreadyAccepted(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR()
@Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR()
@Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR()
@@ -3312,11 +3394,15 @@ sealed class CR {
@Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("acceptingContactRequest") class AcceptingContactRequest(val user: UserRef, val contact: Contact): CR()
@Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR() @Serializable @SerialName("contactRequestRejected") class ContactRequestRejected(val user: UserRef): CR()
@Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR() @Serializable @SerialName("contactUpdated") class ContactUpdated(val user: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("groupMemberUpdated") class GroupMemberUpdated(val user: UserRef, val groupInfo: GroupInfo, val fromMember: GroupMember, val toMember: GroupMember): CR()
// TODO remove below
@Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List<ContactRef>): CR() @Serializable @SerialName("contactsSubscribed") class ContactsSubscribed(val server: String, val contactRefs: List<ContactRef>): CR()
@Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List<ContactRef>): CR() @Serializable @SerialName("contactsDisconnected") class ContactsDisconnected(val server: String, val contactRefs: List<ContactRef>): CR()
@Serializable @SerialName("contactSubError") class ContactSubError(val user: UserRef, val contact: Contact, val chatError: ChatError): CR()
@Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List<ContactSubStatus>): CR() @Serializable @SerialName("contactSubSummary") class ContactSubSummary(val user: UserRef, val contactSubscriptions: List<ContactSubStatus>): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupInfo): CR() // TODO remove above
@Serializable @SerialName("networkStatus") class NetworkStatusResp(val networkStatus: NetworkStatus, val connections: List<String>): CR()
@Serializable @SerialName("networkStatuses") class NetworkStatuses(val user_: UserRef?, val networkStatuses: List<ConnNetworkStatus>): CR()
@Serializable @SerialName("groupSubscribed") class GroupSubscribed(val user: UserRef, val group: GroupRef): CR()
@Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List<MemberSubError>): CR() @Serializable @SerialName("memberSubErrors") class MemberSubErrors(val user: UserRef, val memberSubErrors: List<MemberSubError>): CR()
@Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR() @Serializable @SerialName("groupEmpty") class GroupEmpty(val user: UserRef, val group: GroupInfo): CR()
@Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR() @Serializable @SerialName("userContactLinkSubscribed") class UserContactLinkSubscribed: CR()
@@ -3331,6 +3417,7 @@ sealed class CR {
@Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("groupCreated") class GroupCreated(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR() @Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR() @Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
@Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR()
@Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("userDeletedMember") class UserDeletedMember(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR()
@Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("leftMemberUser") class LeftMemberUser(val user: UserRef, val groupInfo: GroupInfo): CR()
@Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR() @Serializable @SerialName("groupMembers") class GroupMembers(val user: UserRef, val group: Group): CR()
@@ -3421,11 +3508,13 @@ sealed class CR {
is ConnectionVerified -> "connectionVerified" is ConnectionVerified -> "connectionVerified"
is Invitation -> "invitation" is Invitation -> "invitation"
is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated" is ConnectionIncognitoUpdated -> "connectionIncognitoUpdated"
is CRConnectionPlan -> "connectionPlan"
is SentConfirmation -> "sentConfirmation" is SentConfirmation -> "sentConfirmation"
is SentInvitation -> "sentInvitation" is SentInvitation -> "sentInvitation"
is ContactAlreadyExists -> "contactAlreadyExists" is ContactAlreadyExists -> "contactAlreadyExists"
is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted" is ContactRequestAlreadyAccepted -> "contactRequestAlreadyAccepted"
is ContactDeleted -> "contactDeleted" is ContactDeleted -> "contactDeleted"
is ContactDeletedByContact -> "contactDeletedByContact"
is ChatCleared -> "chatCleared" is ChatCleared -> "chatCleared"
is UserProfileNoChange -> "userProfileNoChange" is UserProfileNoChange -> "userProfileNoChange"
is UserProfileUpdated -> "userProfileUpdated" is UserProfileUpdated -> "userProfileUpdated"
@@ -3443,10 +3532,12 @@ sealed class CR {
is AcceptingContactRequest -> "acceptingContactRequest" is AcceptingContactRequest -> "acceptingContactRequest"
is ContactRequestRejected -> "contactRequestRejected" is ContactRequestRejected -> "contactRequestRejected"
is ContactUpdated -> "contactUpdated" is ContactUpdated -> "contactUpdated"
is GroupMemberUpdated -> "groupMemberUpdated"
is ContactsSubscribed -> "contactsSubscribed" is ContactsSubscribed -> "contactsSubscribed"
is ContactsDisconnected -> "contactsDisconnected" is ContactsDisconnected -> "contactsDisconnected"
is ContactSubError -> "contactSubError"
is ContactSubSummary -> "contactSubSummary" is ContactSubSummary -> "contactSubSummary"
is NetworkStatusResp -> "networkStatus"
is NetworkStatuses -> "networkStatuses"
is GroupSubscribed -> "groupSubscribed" is GroupSubscribed -> "groupSubscribed"
is MemberSubErrors -> "memberSubErrors" is MemberSubErrors -> "memberSubErrors"
is GroupEmpty -> "groupEmpty" is GroupEmpty -> "groupEmpty"
@@ -3461,6 +3552,7 @@ sealed class CR {
is GroupCreated -> "groupCreated" is GroupCreated -> "groupCreated"
is SentGroupInvitation -> "sentGroupInvitation" is SentGroupInvitation -> "sentGroupInvitation"
is UserAcceptedGroupSent -> "userAcceptedGroupSent" is UserAcceptedGroupSent -> "userAcceptedGroupSent"
is GroupLinkConnecting -> "groupLinkConnecting"
is UserDeletedMember -> "userDeletedMember" is UserDeletedMember -> "userDeletedMember"
is LeftMemberUser -> "leftMemberUser" is LeftMemberUser -> "leftMemberUser"
is GroupMembers -> "groupMembers" is GroupMembers -> "groupMembers"
@@ -3549,11 +3641,13 @@ sealed class CR {
is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode") is ConnectionVerified -> withUser(user, "verified: $verified\nconnectionCode: $expectedCode")
is Invitation -> withUser(user, connReqInvitation) is Invitation -> withUser(user, connReqInvitation)
is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection)) is ConnectionIncognitoUpdated -> withUser(user, json.encodeToString(toConnection))
is CRConnectionPlan -> withUser(user, json.encodeToString(connectionPlan))
is SentConfirmation -> withUser(user, noDetails()) is SentConfirmation -> withUser(user, noDetails())
is SentInvitation -> withUser(user, noDetails()) is SentInvitation -> withUser(user, noDetails())
is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactAlreadyExists -> withUser(user, json.encodeToString(contact))
is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact)) is ContactRequestAlreadyAccepted -> withUser(user, json.encodeToString(contact))
is ContactDeleted -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact))
is ContactDeletedByContact -> withUser(user, json.encodeToString(contact))
is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo))
is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileNoChange -> withUser(user, noDetails())
is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile))
@@ -3571,10 +3665,12 @@ sealed class CR {
is AcceptingContactRequest -> withUser(user, json.encodeToString(contact)) is AcceptingContactRequest -> withUser(user, json.encodeToString(contact))
is ContactRequestRejected -> withUser(user, noDetails()) is ContactRequestRejected -> withUser(user, noDetails())
is ContactUpdated -> withUser(user, json.encodeToString(toContact)) is ContactUpdated -> withUser(user, json.encodeToString(toContact))
is GroupMemberUpdated -> withUser(user, "groupInfo: $groupInfo\nfromMember: $fromMember\ntoMember: $toMember")
is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsSubscribed -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}" is ContactsDisconnected -> "server: $server\ncontacts:\n${json.encodeToString(contactRefs)}"
is ContactSubError -> withUser(user, "error:\n${chatError.string}\ncontact:\n${json.encodeToString(contact)}")
is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions)) is ContactSubSummary -> withUser(user, json.encodeToString(contactSubscriptions))
is NetworkStatusResp -> "networkStatus $networkStatus\nconnections: $connections"
is NetworkStatuses -> withUser(user_, json.encodeToString(networkStatuses))
is GroupSubscribed -> withUser(user, json.encodeToString(group)) is GroupSubscribed -> withUser(user, json.encodeToString(group))
is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors)) is MemberSubErrors -> withUser(user, json.encodeToString(memberSubErrors))
is GroupEmpty -> withUser(user, json.encodeToString(group)) is GroupEmpty -> withUser(user, json.encodeToString(group))
@@ -3589,6 +3685,7 @@ sealed class CR {
is GroupCreated -> withUser(user, json.encodeToString(groupInfo)) is GroupCreated -> withUser(user, json.encodeToString(groupInfo))
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member") is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
is UserAcceptedGroupSent -> json.encodeToString(groupInfo) is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is UserDeletedMember -> withUser(user, "groupInfo: $groupInfo\nmember: $member")
is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo)) is LeftMemberUser -> withUser(user, json.encodeToString(groupInfo))
is GroupMembers -> withUser(user, json.encodeToString(group)) is GroupMembers -> withUser(user, json.encodeToString(group))
@@ -3660,6 +3757,39 @@ fun chatError(r: CR): ChatErrorType? {
) )
} }
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@Serializable @SerialName("contactAddress") class ContactAddress(val contactAddressPlan: ContactAddressPlan): ConnectionPlan()
@Serializable @SerialName("groupLink") class GroupLink(val groupLinkPlan: GroupLinkPlan): ConnectionPlan()
}
@Serializable
sealed class InvitationLinkPlan {
@Serializable @SerialName("ok") object Ok: InvitationLinkPlan()
@Serializable @SerialName("ownLink") object OwnLink: InvitationLinkPlan()
@Serializable @SerialName("connecting") class Connecting(val contact_: Contact? = null): InvitationLinkPlan()
@Serializable @SerialName("known") class Known(val contact: Contact): InvitationLinkPlan()
}
@Serializable
sealed class ContactAddressPlan {
@Serializable @SerialName("ok") object Ok: ContactAddressPlan()
@Serializable @SerialName("ownLink") object OwnLink: ContactAddressPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: ContactAddressPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val contact: Contact): ContactAddressPlan()
@Serializable @SerialName("known") class Known(val contact: Contact): ContactAddressPlan()
}
@Serializable
sealed class GroupLinkPlan {
@Serializable @SerialName("ok") object Ok: GroupLinkPlan()
@Serializable @SerialName("ownLink") class OwnLink(val groupInfo: GroupInfo): GroupLinkPlan()
@Serializable @SerialName("connectingConfirmReconnect") object ConnectingConfirmReconnect: GroupLinkPlan()
@Serializable @SerialName("connectingProhibit") class ConnectingProhibit(val groupInfo_: GroupInfo? = null): GroupLinkPlan()
@Serializable @SerialName("known") class Known(val groupInfo: GroupInfo): GroupLinkPlan()
}
abstract class TerminalItem { abstract class TerminalItem {
abstract val id: Long abstract val id: Long
val date: Instant = Clock.System.now() val date: Instant = Clock.System.now()
@@ -3819,9 +3949,11 @@ sealed class ChatErrorType {
is ChatNotStarted -> "chatNotStarted" is ChatNotStarted -> "chatNotStarted"
is ChatNotStopped -> "chatNotStopped" is ChatNotStopped -> "chatNotStopped"
is ChatStoreChanged -> "chatStoreChanged" is ChatStoreChanged -> "chatStoreChanged"
is ConnectionPlanChatError -> "connectionPlan"
is InvalidConnReq -> "invalidConnReq" is InvalidConnReq -> "invalidConnReq"
is InvalidChatMessage -> "invalidChatMessage" is InvalidChatMessage -> "invalidChatMessage"
is ContactNotReady -> "contactNotReady" is ContactNotReady -> "contactNotReady"
is ContactNotActive -> "contactNotActive"
is ContactDisabled -> "contactDisabled" is ContactDisabled -> "contactDisabled"
is ConnectionDisabled -> "connectionDisabled" is ConnectionDisabled -> "connectionDisabled"
is GroupUserRole -> "groupUserRole" is GroupUserRole -> "groupUserRole"
@@ -3894,9 +4026,11 @@ sealed class ChatErrorType {
@Serializable @SerialName("chatNotStarted") object ChatNotStarted: ChatErrorType() @Serializable @SerialName("chatNotStarted") object ChatNotStarted: ChatErrorType()
@Serializable @SerialName("chatNotStopped") object ChatNotStopped: ChatErrorType() @Serializable @SerialName("chatNotStopped") object ChatNotStopped: ChatErrorType()
@Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType() @Serializable @SerialName("chatStoreChanged") object ChatStoreChanged: ChatErrorType()
@Serializable @SerialName("connectionPlan") class ConnectionPlanChatError(val connectionPlan: ConnectionPlan): ChatErrorType()
@Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType() @Serializable @SerialName("invalidConnReq") object InvalidConnReq: ChatErrorType()
@Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType() @Serializable @SerialName("invalidChatMessage") class InvalidChatMessage(val connection: Connection, val message: String): ChatErrorType()
@Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactNotReady") class ContactNotReady(val contact: Contact): ChatErrorType()
@Serializable @SerialName("contactNotActive") class ContactNotActive(val contact: Contact): ChatErrorType()
@Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType() @Serializable @SerialName("contactDisabled") class ContactDisabled(val contact: Contact): ChatErrorType()
@Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType() @Serializable @SerialName("connectionDisabled") class ConnectionDisabled(val connection: Connection): ChatErrorType()
@Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType() @Serializable @SerialName("groupUserRole") class GroupUserRole(val groupInfo: GroupInfo, val requiredRole: GroupMemberRole): ChatErrorType()
@@ -20,6 +20,7 @@ external fun chatRecvMsgWait(ctrl: ChatCtrl, timeout: Int): String
external fun chatParseMarkdown(str: String): String external fun chatParseMarkdown(str: String): String
external fun chatParseServer(str: String): String external fun chatParseServer(str: String): String
external fun chatPasswordHash(pwd: String, salt: String): String external fun chatPasswordHash(pwd: String, salt: String): String
external fun chatValidName(name: String): String
external fun chatWriteFile(path: String, buffer: ByteBuffer): String external fun chatWriteFile(path: String, buffer: ByteBuffer): String
external fun chatReadFile(path: String, key: String, nonce: String): Array<Any> external fun chatReadFile(path: String, key: String, nonce: String): Array<Any>
external fun chatEncryptFile(fromPath: String, toPath: String): String external fun chatEncryptFile(fromPath: String, toPath: String): String
@@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import chat.simplex.common.views.chat.ComposeState import chat.simplex.common.views.chat.ComposeState
import java.io.File
import java.net.URI
@Composable @Composable
expect fun PlatformTextField( expect fun PlatformTextField(
@@ -14,5 +16,6 @@ expect fun PlatformTextField(
userIsObserver: Boolean, userIsObserver: Boolean,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
onUpArrow: () -> Unit, onUpArrow: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onDone: () -> Unit, onDone: () -> Unit,
) )
@@ -14,3 +14,5 @@ expect fun LocalMultiplatformView(): Any?
@Composable @Composable
expect fun getKeyboardState(): State<KeyboardState> expect fun getKeyboardState(): State<KeyboardState>
expect fun hideKeyboard(view: Any?) expect fun hideKeyboard(view: Any?)
expect fun androidIsFinishingMainActivity(): Boolean
@@ -97,6 +97,7 @@ fun TerminalLayout(
updateLiveMessage = null, updateLiveMessage = null,
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = ::onMessageChange, onMessageChange = ::onMessageChange,
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }
@@ -123,7 +124,18 @@ fun TerminalLog(terminalItems: List<TerminalItem>) {
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
} }
val reversedTerminalItems by remember { derivedStateOf { terminalItems.reversed().toList() } } val reversedTerminalItems by remember {
derivedStateOf {
// Such logic prevents concurrent modification
val res = ArrayList<TerminalItem>()
var i = 0
while (i < terminalItems.size) {
res.add(terminalItems[i])
i++
}
res.asReversed()
}
}
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
LazyColumn(state = listState, reverseLayout = true) { LazyColumn(state = listState, reverseLayout = true) {
items(reversedTerminalItems) { item -> items(reversedTerminalItems) { item ->
@@ -1,10 +1,10 @@
package chat.simplex.common.views package chat.simplex.common.views
import SectionTextFooter
import androidx.compose.foundation.* import androidx.compose.foundation.*
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors import androidx.compose.material.MaterialTheme.colors
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -18,115 +18,160 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.* import androidx.compose.ui.text.style.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.Profile import chat.simplex.common.model.Profile
import chat.simplex.common.platform.appPlatform import chat.simplex.common.platform.*
import chat.simplex.common.platform.navigationBarsWithImePadding
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage import chat.simplex.common.views.onboarding.*
import chat.simplex.common.views.onboarding.ReadableText import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
fun isValidDisplayName(name: String) : Boolean {
return (name.firstOrNull { it.isWhitespace() }) == null && !name.startsWith("@") && !name.startsWith("#")
}
@Composable @Composable
fun CreateProfilePanel(chatModel: ChatModel, close: () -> Unit) { fun CreateProfile(chatModel: ChatModel, close: () -> Unit) {
val displayName = rememberSaveable { mutableStateOf("") } val scope = rememberCoroutineScope()
val fullName = rememberSaveable { mutableStateOf("") } val scrollState = rememberScrollState()
val focusRequester = remember { FocusRequester() } val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
Column( ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()) Box(
) { modifier = Modifier
/*CloseSheetBar(close = { .fillMaxSize()
if (chatModel.users.isEmpty()) { .padding(top = 20.dp)
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo ) {
} else { val displayName = rememberSaveable { mutableStateOf("") }
close() val focusRequester = remember { FocusRequester() }
}
})*/ Column(
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) { modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING) ) {
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1) Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1) AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
Spacer(Modifier.height(DEFAULT_PADDING)) Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { Text(
Text( stringResource(MR.strings.display_name),
stringResource(MR.strings.display_name), fontSize = 16.sp
fontSize = 16.sp )
) val name = displayName.value.trim()
if (!isValidDisplayName(displayName.value)) { val validName = mkValidName(name)
Text( Spacer(Modifier.height(20.dp))
stringResource(MR.strings.no_spaces), if (name != validName) {
fontSize = 16.sp, IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) {
color = Color.Red Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error)
) }
} }
} }
ProfileNameField(displayName, "", ::isValidDisplayName, focusRequester) ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester)
Spacer(Modifier.height(DEFAULT_PADDING)) }
Text( SettingsActionItem(
stringResource(MR.strings.full_name_optional__prompt), painterResource(MR.images.ic_check),
fontSize = 16.sp, stringResource(MR.strings.create_another_profile_button),
modifier = Modifier.padding(bottom = DEFAULT_PADDING_HALF) disabled = !canCreateProfile(displayName.value),
) textColor = MaterialTheme.colors.primary,
ProfileNameField(fullName, "") iconColor = MaterialTheme.colors.primary,
} click = { createProfileInProfiles(chatModel, displayName.value, close) },
Spacer(Modifier.fillMaxHeight().weight(1f)) )
Row { SectionTextFooter(generalGetString(MR.strings.your_profile_is_stored_on_your_device))
if (chatModel.users.isEmpty()) { SectionTextFooter(generalGetString(MR.strings.profile_is_only_shared_with_your_contacts))
SimpleButtonDecorated(
text = stringResource(MR.strings.about_simplex), LaunchedEffect(Unit) {
icon = painterResource(MR.images.ic_arrow_back_ios_new), delay(300)
textDecoration = TextDecoration.None, focusRequester.requestFocus()
fontWeight = FontWeight.Medium }
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) } }
} if (savedKeyboardState != keyboardState) {
Spacer(Modifier.fillMaxWidth().weight(1f)) LaunchedEffect(keyboardState) {
val enabled = displayName.value.isNotEmpty() && isValidDisplayName(displayName.value) scope.launch {
val createModifier: Modifier savedKeyboardState = keyboardState
val createColor: Color scrollState.animateScrollTo(scrollState.maxValue)
if (enabled) {
createModifier = Modifier.clickable {
if (chatModel.controller.appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
createProfileInProfiles(chatModel, displayName.value, fullName.value, close)
} else {
createProfileOnboarding(chatModel, displayName.value, fullName.value, close)
} }
}.padding(8.dp)
createColor = MaterialTheme.colors.primary
} else {
createModifier = Modifier.padding(8.dp)
createColor = MaterialTheme.colors.secondary
}
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor)
} }
} }
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
} }
} }
} }
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { @Composable
fun CreateFirstProfile(chatModel: ChatModel, close: () -> Unit) {
val scope = rememberCoroutineScope()
val scrollState = rememberScrollState()
val keyboardState by getKeyboardState()
var savedKeyboardState by remember { mutableStateOf(keyboardState) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(top = 20.dp)
) {
val displayName = rememberSaveable { mutableStateOf("") }
val focusRequester = remember { FocusRequester() }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState())
) {
/*CloseSheetBar(close = {
if (chatModel.users.isEmpty()) {
chatModel.onboardingStage.value = OnboardingStage.Step1_SimpleXInfo
} else {
close()
}
})*/
Column(Modifier.padding(horizontal = DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.create_profile), bottomPadding = DEFAULT_PADDING)
ReadableText(MR.strings.your_profile_is_stored_on_your_device, TextAlign.Center, padding = PaddingValues(), style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
ReadableText(MR.strings.profile_is_only_shared_with_your_contacts, TextAlign.Center, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
Spacer(Modifier.height(DEFAULT_PADDING))
Row(Modifier.padding(bottom = DEFAULT_PADDING_HALF).fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
stringResource(MR.strings.display_name),
fontSize = 16.sp
)
val name = displayName.value.trim()
val validName = mkValidName(name)
Spacer(Modifier.height(20.dp))
if (name != validName) {
IconButton({ showInvalidNameAlert(mkValidName(displayName.value), displayName) }, Modifier.size(20.dp)) {
Icon(painterResource(MR.images.ic_info), null, tint = MaterialTheme.colors.error)
}
}
}
ProfileNameField(displayName, "", { it.trim() == mkValidName(it) }, focusRequester)
}
Spacer(Modifier.fillMaxHeight().weight(1f))
OnboardingButtons(displayName, close)
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
LaunchedEffect(Unit) {
setLastVersionDefault(chatModel)
}
if (savedKeyboardState != keyboardState) {
LaunchedEffect(keyboardState) {
scope.launch {
savedKeyboardState = keyboardState
scrollState.animateScrollTo(scrollState.maxValue)
}
}
}
}
}
}
fun createProfileInProfiles(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withApi { withApi {
val user = chatModel.controller.apiCreateActiveUser( val user = chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null) Profile(displayName.trim(), "", null)
) ?: return@withApi ) ?: return@withApi
chatModel.currentUser.value = user chatModel.currentUser.value = user
if (chatModel.users.isEmpty()) { if (chatModel.users.isEmpty()) {
@@ -142,10 +187,10 @@ fun createProfileInProfiles(chatModel: ChatModel, displayName: String, fullName:
} }
} }
fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName: String, close: () -> Unit) { fun createProfileOnboarding(chatModel: ChatModel, displayName: String, close: () -> Unit) {
withApi { withApi {
chatModel.controller.apiCreateActiveUser( chatModel.controller.apiCreateActiveUser(
Profile(displayName, fullName, null) Profile(displayName.trim(), "", null)
) ?: return@withApi ) ?: return@withApi
val onboardingStage = chatModel.controller.appPrefs.onboardingStage val onboardingStage = chatModel.controller.appPrefs.onboardingStage
if (chatModel.users.isEmpty()) { if (chatModel.users.isEmpty()) {
@@ -163,6 +208,28 @@ fun createProfileOnboarding(chatModel: ChatModel, displayName: String, fullName:
} }
} }
@Composable
fun OnboardingButtons(displayName: MutableState<String>, close: () -> Unit) {
Row {
SimpleButtonDecorated(
text = stringResource(MR.strings.about_simplex),
icon = painterResource(MR.images.ic_arrow_back_ios_new),
textDecoration = TextDecoration.None,
fontWeight = FontWeight.Medium
) { chatModel.controller.appPrefs.onboardingStage.set(OnboardingStage.Step1_SimpleXInfo) }
Spacer(Modifier.fillMaxWidth().weight(1f))
val enabled = canCreateProfile(displayName.value)
val createModifier: Modifier = Modifier.clickable(enabled) { createProfileOnboarding(chatModel, displayName.value, close) }.padding(8.dp)
val createColor: Color = if (enabled) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = createModifier) {
Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = createColor, fontWeight = FontWeight.Medium)
Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = createColor)
}
}
}
}
@Composable @Composable
fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) { fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isValid: (String) -> Boolean = { true }, focusRequester: FocusRequester? = null) {
var valid by rememberSaveable { mutableStateOf(true) } var valid by rememberSaveable { mutableStateOf(true) }
@@ -195,10 +262,6 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
onValueChange = { name.value = it }, onValueChange = { name.value = it },
modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester), modifier = if (focusRequester == null) modifier else modifier.focusRequester(focusRequester),
textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground), textStyle = TextStyle(fontSize = 18.sp, color = colors.onBackground),
keyboardOptions = KeyboardOptions(
capitalization = KeyboardCapitalization.None,
autoCorrect = false
),
singleLine = true, singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colors.secondary) cursorBrush = SolidColor(MaterialTheme.colors.secondary)
) )
@@ -211,3 +274,28 @@ fun ProfileNameField(name: MutableState<String>, placeholder: String = "", isVal
} }
} }
} }
private fun canCreateProfile(displayName: String): Boolean {
val name = displayName.trim()
return name.isNotEmpty() && mkValidName(name) == name
}
fun showInvalidNameAlert(name: String, displayName: MutableState<String>) {
if (name.isEmpty()) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.invalid_name),
)
} else {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.invalid_name),
text = generalGetString(MR.strings.correct_name_to).format(name),
onConfirm = {
displayName.value = name
}
)
}
}
fun isValidDisplayName(name: String) : Boolean = mkValidName(name.trim()) == name
fun mkValidName(s: String): String = chatValidName(s)
@@ -3,8 +3,6 @@ package chat.simplex.common.views.call
import chat.simplex.common.model.ChatModel import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.helpers.withApi import chat.simplex.common.views.helpers.withApi
import chat.simplex.common.views.helpers.withBGApi
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
@@ -26,10 +24,6 @@ class CallManager(val chatModel: ChatModel) {
} }
fun acceptIncomingCall(invitation: RcvCallInvitation) { fun acceptIncomingCall(invitation: RcvCallInvitation) {
if (appPlatform.isDesktop) {
return showInDevelopingAlert()
}
val call = chatModel.activeCall.value val call = chatModel.activeCall.value
if (call == null) { if (call == null) {
justAcceptIncomingCall(invitation = invitation) justAcceptIncomingCall(invitation = invitation)
@@ -58,12 +52,12 @@ class CallManager(val chatModel: ChatModel) {
val useRelay = controller.appPrefs.webrtcPolicyRelay.get() val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
val iceServers = getIceServers() val iceServers = getIceServers()
Log.d(TAG, "answerIncomingCall iceServers: $iceServers") Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
callCommand.value = WCallCommand.Start( callCommand.add(WCallCommand.Start(
media = invitation.callType.media, media = invitation.callType.media,
aesKey = invitation.sharedKey, aesKey = invitation.sharedKey,
iceServers = iceServers, iceServers = iceServers,
relay = useRelay relay = useRelay
) ))
callInvitations.remove(invitation.contact.id) callInvitations.remove(invitation.contact.id)
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) { if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
activeCallInvitation.value = null activeCallInvitation.value = null
@@ -80,7 +74,7 @@ class CallManager(val chatModel: ChatModel) {
showCallView.value = false showCallView.value = false
} else { } else {
Log.d(TAG, "CallManager.endCall: ending call...") Log.d(TAG, "CallManager.endCall: ending call...")
callCommand.value = WCallCommand.End callCommand.add(WCallCommand.End)
showCallView.value = false showCallView.value = false
controller.apiEndCall(call.contact) controller.apiEndCall(call.contact)
activeCall.value = null activeCall.value = null
@@ -1,7 +1,5 @@
package chat.simplex.common.views.call package chat.simplex.common.views.call
import androidx.compose.runtime.Composable
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.views.helpers.generalGetString import chat.simplex.common.views.helpers.generalGetString
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.res.MR import chat.simplex.res.MR
@@ -23,16 +21,17 @@ data class Call(
val videoEnabled: Boolean = localMedia == CallMediaType.Video, val videoEnabled: Boolean = localMedia == CallMediaType.Video,
val soundSpeaker: Boolean = localMedia == CallMediaType.Video, val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
var localCamera: VideoCamera = VideoCamera.User, var localCamera: VideoCamera = VideoCamera.User,
val connectionInfo: ConnectionInfo? = null val connectionInfo: ConnectionInfo? = null,
var connectedAt: Instant? = null
) { ) {
val encrypted: Boolean get() = localEncrypted && sharedKey != null val encrypted: Boolean get() = localEncrypted && sharedKey != null
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
val encryptionStatus: String @Composable get() = when(callState) { val encryptionStatus: String get() = when(callState) {
CallState.WaitCapabilities -> "" CallState.WaitCapabilities -> ""
CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption) CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption) CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted) else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
} }
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
@@ -49,16 +48,16 @@ enum class CallState {
Connected, Connected,
Ended; Ended;
val text: String @Composable get() = when(this) { val text: String get() = when(this) {
WaitCapabilities -> stringResource(MR.strings.callstate_starting) WaitCapabilities -> generalGetString(MR.strings.callstate_starting)
InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer) InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer)
InvitationAccepted -> stringResource(MR.strings.callstate_starting) InvitationAccepted -> generalGetString(MR.strings.callstate_starting)
OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation) OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation)
OfferReceived -> stringResource(MR.strings.callstate_received_answer) OfferReceived -> generalGetString(MR.strings.callstate_received_answer)
AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation) AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation)
Negotiated -> stringResource(MR.strings.callstate_connecting) Negotiated -> generalGetString(MR.strings.callstate_connecting)
Connected -> stringResource(MR.strings.callstate_connected) Connected -> generalGetString(MR.strings.callstate_connected)
Ended -> stringResource(MR.strings.callstate_ended) Ended -> generalGetString(MR.strings.callstate_ended)
} }
} }
@@ -67,13 +66,14 @@ enum class CallState {
@Serializable @Serializable
sealed class WCallCommand { sealed class WCallCommand {
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand() @Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand() @Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand() @Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand() @Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand() @Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
@Serializable @SerialName("end") object End: WCallCommand() @Serializable @SerialName("end") object End: WCallCommand()
} }
@@ -85,6 +85,7 @@ sealed class WCallResponse {
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse() @Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse() @Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse() @Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
@Serializable @SerialName("end") object End: WCallResponse()
@Serializable @SerialName("ended") object Ended: WCallResponse() @Serializable @SerialName("ended") object Ended: WCallResponse()
@Serializable @SerialName("ok") object Ok: WCallResponse() @Serializable @SerialName("ok") object Ok: WCallResponse()
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse() @Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
@@ -106,14 +107,14 @@ sealed class WCallResponse {
} }
@Serializable data class CallCapabilities(val encryption: Boolean) @Serializable data class CallCapabilities(val encryption: Boolean)
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) { @Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
val text: String @Composable get() { val text: String get() {
val local = localCandidate?.candidateType val local = localCandidate?.candidateType
val remote = remoteCandidate?.candidateType val remote = remoteCandidate?.candidateType
return when { return when {
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host -> local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
stringResource(MR.strings.call_connection_peer_to_peer) generalGetString(MR.strings.call_connection_peer_to_peer)
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay -> local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
stringResource(MR.strings.call_connection_via_relay) generalGetString(MR.strings.call_connection_via_relay)
else -> else ->
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}" "${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
} }
@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.* import androidx.compose.foundation.text.*
import androidx.compose.material.* import androidx.compose.material.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -32,10 +31,10 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.* import chat.simplex.common.model.*
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode
import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.*
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.chatlist.updateChatSettings
import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -197,28 +196,67 @@ sealed class SendReceipts {
} }
fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { fun deleteContactDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertDialog( AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question), title = generalGetString(MR.strings.delete_contact_question),
text = generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning), text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)),
confirmText = generalGetString(MR.strings.delete_verb), buttons = {
onConfirm = { Column {
withApi { if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId) // Delete and notify contact
if (r) { SectionItemView({
chatModel.removeChat(chatInfo.id) AlertManager.shared.hideAlert()
if (chatModel.chatId.value == chatInfo.id) { withApi {
chatModel.chatId.value = null deleteContact(chatInfo, chatModel, close, notify = true)
ModalManager.end.closeModals() }
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
} }
ntfManager.cancelNotificationsForChat(chatInfo.id) // Delete
close?.invoke() SectionItemView({
AlertManager.shared.hideAlert()
withApi {
deleteContact(chatInfo, chatModel, close, notify = false)
}
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
withApi {
deleteContact(chatInfo, chatModel, close)
}
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
} }
} }
}, }
destructive = true,
) )
} }
fun deleteContact(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
withApi {
val r = chatModel.controller.apiDeleteChat(chatInfo.chatType, chatInfo.apiId, notify)
if (r) {
chatModel.removeChat(chatInfo.id)
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
}
ntfManager.cancelNotificationsForChat(chatInfo.id)
close?.invoke()
}
}
}
fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) { fun clearChatDialog(chatInfo: ChatInfo, chatModel: ChatModel, close: (() -> Unit)? = null) {
AlertManager.shared.showAlertDialog( AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.clear_chat_question), title = generalGetString(MR.strings.clear_chat_question),
@@ -291,7 +329,7 @@ fun ChatInfoLayout(
SectionDividerSpaced() SectionDividerSpaced()
} }
if (contact.ready) { if (contact.ready && contact.active) {
SectionView { SectionView {
if (connectionCode != null) { if (connectionCode != null) {
VerifyCodeButton(contact.verified, verifyClicked) VerifyCodeButton(contact.verified, verifyClicked)
@@ -310,15 +348,15 @@ fun ChatInfoLayout(
if (contact.contactLink != null) { if (contact.contactLink != null) {
SectionView(stringResource(MR.strings.address_section_title).uppercase()) { SectionView(stringResource(MR.strings.address_section_title).uppercase()) {
QRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f)) SimpleXLinkQRCode(contact.contactLink, Modifier.padding(horizontal = DEFAULT_PADDING, vertical = DEFAULT_PADDING_HALF).aspectRatio(1f))
val clipboard = LocalClipboardManager.current val clipboard = LocalClipboardManager.current
ShareAddressButton { clipboard.shareText(contact.contactLink) } ShareAddressButton { clipboard.shareText(simplexChatLink(contact.contactLink)) }
SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName)) SectionTextFooter(stringResource(MR.strings.you_can_share_this_address_with_your_contacts).format(contact.displayName))
} }
SectionDividerSpaced() SectionDividerSpaced()
} }
if (contact.ready) { if (contact.ready && contact.active) {
SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) { SectionView(title = stringResource(MR.strings.conn_stats_section_title_servers)) {
SectionItemView({ SectionItemView({
AlertManager.shared.showAlertMsg( AlertManager.shared.showAlertMsg(
@@ -33,7 +33,6 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.GroupInfo import chat.simplex.common.model.GroupInfo
import chat.simplex.common.platform.* import chat.simplex.common.platform.*
import chat.simplex.common.platform.AudioPlayer import chat.simplex.common.platform.AudioPlayer
import chat.simplex.common.views.usersettings.showInDevelopingAlert
import chat.simplex.res.MR import chat.simplex.res.MR
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
@@ -66,11 +65,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
launch { launch {
snapshotFlow { chatModel.chatId.value } snapshotFlow { chatModel.chatId.value }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chatId: activeChatId ${activeChat.value?.id} == new chatId $it ${activeChat.value?.id == it} ") }
.filter { it != null && activeChat.value?.id != it } .filter { it != null && activeChat.value?.id != it }
.collect { chatId -> .collect { chatId ->
// Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly // Redisplay the whole hierarchy if the chat is different to make going from groups to direct chat working correctly
// Also for situation when chatId changes after clicking in notification, etc // Also for situation when chatId changes after clicking in notification, etc
activeChat.value = chatModel.getChat(chatId!!) activeChat.value = chatModel.getChat(chatId!!)
Log.d(TAG, "TODOCHAT: chatId: activeChatId became ${activeChat.value?.id}")
markUnreadChatAsRead(activeChat, chatModel) markUnreadChatAsRead(activeChat, chatModel)
} }
} }
@@ -89,9 +90,13 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
} }
} }
.distinctUntilChanged() .distinctUntilChanged()
.onEach { Log.d(TAG, "TODOCHAT: chats: activeChatId ${activeChat.value?.id} == new chatId ${it?.id} ${activeChat.value?.id == it?.id} ") }
// Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions // Only changed chatInfo is important thing. Other properties can be skipped for reducing recompositions
.filter { it != null && it?.chatInfo != activeChat.value?.chatInfo } .filter { it != null && it?.chatInfo != activeChat.value?.chatInfo }
.collect { activeChat.value = it } .collect {
activeChat.value = it
Log.d(TAG, "TODOCHAT: chats: activeChatId became ${activeChat.value?.id}")
}
} }
} }
val view = LocalMultiplatformView() val view = LocalMultiplatformView()
@@ -118,7 +123,12 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally horizontalAlignment = Alignment.CenterHorizontally
) { ) {
if (chat.chatInfo is ChatInfo.Direct && !chat.chatInfo.contact.ready && !chat.chatInfo.contact.nextSendGrpInv) { if (
chat.chatInfo is ChatInfo.Direct
&& !chat.chatInfo.contact.ready
&& chat.chatInfo.contact.active
&& !chat.chatInfo.contact.nextSendGrpInv
) {
Text( Text(
generalGetString(MR.strings.contact_connection_pending), generalGetString(MR.strings.contact_connection_pending),
Modifier.padding(top = 4.dp), Modifier.padding(top = 4.dp),
@@ -213,7 +223,9 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
val firstId = chatModel.chatItems.firstOrNull()?.id val firstId = chatModel.chatItems.firstOrNull()?.id
if (c != null && firstId != null) { if (c != null && firstId != null) {
withApi { withApi {
Log.d(TAG, "TODOCHAT: loadPrevMessages: loading for ${c.id}, current chatId ${ChatModel.chatId.value}, size was ${ChatModel.chatItems.size}")
apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value) apiLoadPrevMessages(c.chatInfo, chatModel, firstId, searchText.value)
Log.d(TAG, "TODOCHAT: loadPrevMessages: loaded for ${c.id}, current chatId ${ChatModel.chatId.value}, size now ${ChatModel.chatItems.size}")
} }
} }
}, },
@@ -257,27 +269,31 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
cancelFile = { fileId -> cancelFile = { fileId ->
withApi { chatModel.controller.cancelFile(user, fileId) } withApi { chatModel.controller.cancelFile(user, fileId) }
}, },
joinGroup = { groupId -> joinGroup = { groupId, onComplete ->
withApi { chatModel.controller.apiJoinGroup(groupId) } withApi {
chatModel.controller.apiJoinGroup(groupId)
onComplete.invoke()
}
}, },
startCall = out@ { media -> startCall = out@ { media ->
if (appPlatform.isDesktop) {
return@out showInDevelopingAlert()
}
withBGApi { withBGApi {
val cInfo = chat.chatInfo val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) { if (cInfo is ChatInfo.Direct) {
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media) chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
chatModel.showCallView.value = true chatModel.showCallView.value = true
chatModel.callCommand.value = WCallCommand.Capabilities chatModel.callCommand.add(WCallCommand.Capabilities(media))
} }
} }
}, },
endCall = {
val call = chatModel.activeCall.value
if (call != null) withApi { chatModel.callManager.endCall(call) }
},
acceptCall = { contact -> acceptCall = { contact ->
hideKeyboard(view) hideKeyboard(view)
val invitation = chatModel.callInvitations.remove(contact.id) val invitation = chatModel.callInvitations.remove(contact.id)
if (invitation == null) { if (invitation == null) {
AlertManager.shared.showAlertMsg("Call already ended!") AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
} else { } else {
chatModel.callManager.acceptIncomingCall(invitation = invitation) chatModel.callManager.acceptIncomingCall(invitation = invitation)
} }
@@ -373,6 +389,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
} }
} }
}, },
openGroupLink = { groupInfo ->
hideKeyboard(view)
withApi {
val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
}
}
},
markRead = { range, unreadCountAfter -> markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter) chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
ntfManager.cancelNotificationsForChat(chat.id) ntfManager.cancelNotificationsForChat(chat.id)
@@ -394,6 +420,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
} }
}, },
onComposed, onComposed,
developerTools = chatModel.controller.appPrefs.developerTools.get(),
) )
} }
} }
@@ -417,8 +444,9 @@ fun ChatLayout(
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long, Boolean) -> Unit, receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit, cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit,
startCall: (CallMediaType) -> Unit, startCall: (CallMediaType) -> Unit,
endCall: () -> Unit,
acceptCall: (Contact) -> Unit, acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
openDirectChat: (Long) -> Unit, openDirectChat: (Long) -> Unit,
@@ -431,10 +459,12 @@ fun ChatLayout(
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit, setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit, showItemDetails: (ChatInfo, ChatItem) -> Unit,
addMembers: (GroupInfo) -> Unit, addMembers: (GroupInfo) -> Unit,
openGroupLink: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit, onSearchValueChanged: (String) -> Unit,
onComposed: suspend (chatId: String) -> Unit, onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } } val attachmentDisabled = remember { derivedStateOf { composeState.value.attachmentDisabled } }
@@ -443,17 +473,7 @@ fun ChatLayout(
.fillMaxWidth() .fillMaxWidth()
.desktopOnExternalDrag( .desktopOnExternalDrag(
enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value, enabled = !attachmentDisabled.value && rememberUpdatedState(chat.userCanSend).value,
onFiles = { paths -> onFiles = { paths -> composeState.onFilesAttached(paths.map { URI.create(it) }) },
val uris = paths.map { URI.create(it) }
val groups = uris.groupBy { isImage(it) }
val images = groups[true] ?: emptyList()
val files = groups[false] ?: emptyList()
if (images.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch { composeState.processPickedMedia(images, null) }
} else if (files.isNotEmpty()) {
composeState.processPickedFile(uris.first(), null)
}
},
onImage = { onImage = {
val tmpFile = File.createTempFile("image", ".bmp", tmpDir) val tmpFile = File.createTempFile("image", ".bmp", tmpDir)
tmpFile.deleteOnExit() tmpFile.deleteOnExit()
@@ -486,7 +506,7 @@ fun ChatLayout(
} }
Scaffold( Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) }, topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) },
bottomBar = composeView, bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(), modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() }, floatingActionButton = { floatingButton.value() },
@@ -500,7 +520,7 @@ fun ChatLayout(
useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage, useLinkPreviews, linkMode, showMemberInfo, loadPrevMessages, deleteMessage,
receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat, receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature, openDirectChat,
updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember, updateContactStats, updateMemberStats, syncContactConnection, syncMemberConnection, findModelChat, findModelMember,
setReaction, showItemDetails, markRead, setFloatingButton, onComposed, setReaction, showItemDetails, markRead, setFloatingButton, onComposed, developerTools,
) )
} }
} }
@@ -515,7 +535,9 @@ fun ChatInfoToolbar(
back: () -> Unit, back: () -> Unit,
info: () -> Unit, info: () -> Unit,
startCall: (CallMediaType) -> Unit, startCall: (CallMediaType) -> Unit,
endCall: () -> Unit,
addMembers: (GroupInfo) -> Unit, addMembers: (GroupInfo) -> Unit,
openGroupLink: (GroupInfo) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit, changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit, onSearchValueChanged: (String) -> Unit,
) { ) {
@@ -535,6 +557,7 @@ fun ChatInfoToolbar(
} }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>() val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val menuItems = arrayListOf<@Composable () -> Unit>() val menuItems = arrayListOf<@Composable () -> Unit>()
val activeCall by remember { chatModel.activeCall }
menuItems.add { menuItems.add {
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = { ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
showMenu.value = false showMenu.value = false
@@ -543,20 +566,52 @@ fun ChatInfoToolbar(
} }
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) { if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) {
barButtons.add { if (activeCall == null) {
IconButton({ barButtons.add {
showMenu.value = false IconButton(
startCall(CallMediaType.Audio) {
}, showMenu.value = false
enabled = chat.chatInfo.contact.ready) { startCall(CallMediaType.Audio)
Icon( },
painterResource(MR.images.ic_call_500), enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active
stringResource(MR.strings.icon_descr_more_button), ) {
tint = if (chat.chatInfo.contact.ready) MaterialTheme.colors.primary else MaterialTheme.colors.secondary Icon(
) painterResource(MR.images.ic_call_500),
stringResource(MR.strings.icon_descr_more_button),
tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
)
}
}
} else if (activeCall?.contact?.id == chat.id) {
barButtons.add {
val call = remember { chatModel.activeCall }.value
val connectedAt = call?.connectedAt
if (connectedAt != null) {
val time = remember { mutableStateOf(durationText(0)) }
LaunchedEffect(Unit) {
while (true) {
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
delay(250)
}
}
val sp50 = with(LocalDensity.current) { 50.sp.toDp() }
Text(time.value, Modifier.widthIn(min = sp50))
}
}
barButtons.add {
IconButton({
showMenu.value = false
endCall()
}) {
Icon(
painterResource(MR.images.ic_call_end_filled),
null,
tint = MaterialTheme.colors.error
)
}
} }
} }
if (chat.chatInfo.contact.ready) { if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active && activeCall == null) {
menuItems.add { menuItems.add {
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = { ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
showMenu.value = false showMenu.value = false
@@ -564,17 +619,28 @@ fun ChatInfoToolbar(
}) })
} }
} }
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) { } else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) {
barButtons.add { if (!chat.chatInfo.incognito) {
IconButton({ barButtons.add {
showMenu.value = false IconButton({
addMembers(chat.chatInfo.groupInfo) showMenu.value = false
}) { addMembers(chat.chatInfo.groupInfo)
Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary) }) {
Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary)
}
}
} else {
barButtons.add {
IconButton({
showMenu.value = false
openGroupLink(chat.chatInfo.groupInfo)
}) {
Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary)
}
} }
} }
} }
if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready) || chat.chatInfo is ChatInfo.Group) { if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) } val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
menuItems.add { menuItems.add {
ItemAction( ItemAction(
@@ -680,7 +746,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
deleteMessage: (Long, CIDeleteMode) -> Unit, deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long, Boolean) -> Unit, receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit, cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit, joinGroup: (Long, () -> Unit) -> Unit,
acceptCall: (Contact) -> Unit, acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit, acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
openDirectChat: (Long) -> Unit, openDirectChat: (Long) -> Unit,
@@ -695,6 +761,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit, markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
setFloatingButton: (@Composable () -> Unit) -> Unit, setFloatingButton: (@Composable () -> Unit) -> Unit,
onComposed: suspend (chatId: String) -> Unit, onComposed: suspend (chatId: String) -> Unit,
developerTools: Boolean,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -831,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) { ) {
MemberImage(member) MemberImage(member)
} }
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames) ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
} }
} }
} else { } else {
@@ -840,7 +907,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp) .padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
.then(swipeableModifier) .then(swipeableModifier)
) { ) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames) ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
} }
} }
} }
@@ -850,7 +917,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp) .padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
.then(swipeableModifier) .then(swipeableModifier)
) { ) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
} }
} }
} else { // direct message } else { // direct message
@@ -861,7 +928,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp, end = if (sent || voiceWithTransparentBack) 12.dp else 76.dp,
).then(swipeableModifier) ).then(swipeableModifier)
) { ) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails) ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
} }
} }
@@ -1282,8 +1349,9 @@ fun PreviewChatLayout() {
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
receiveFile = { _, _ -> }, receiveFile = { _, _ -> },
cancelFile = {}, cancelFile = {},
joinGroup = {}, joinGroup = { _, _ -> },
startCall = {}, startCall = {},
endCall = {},
acceptCall = { _ -> }, acceptCall = { _ -> },
acceptFeature = { _, _, _ -> }, acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> }, openDirectChat = { _ -> },
@@ -1296,10 +1364,12 @@ fun PreviewChatLayout() {
setReaction = { _, _, _, _ -> }, setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> }, showItemDetails = { _, _ -> },
addMembers = { _ -> }, addMembers = { _ -> },
openGroupLink = {},
markRead = { _, _ -> }, markRead = { _, _ -> },
changeNtfsState = { _, _ -> }, changeNtfsState = { _, _ -> },
onSearchValueChanged = {}, onSearchValueChanged = {},
onComposed = {}, onComposed = {},
developerTools = false,
) )
} }
} }
@@ -1350,8 +1420,9 @@ fun PreviewGroupChatLayout() {
deleteMessage = { _, _ -> }, deleteMessage = { _, _ -> },
receiveFile = { _, _ -> }, receiveFile = { _, _ -> },
cancelFile = {}, cancelFile = {},
joinGroup = {}, joinGroup = { _, _ -> },
startCall = {}, startCall = {},
endCall = {},
acceptCall = { _ -> }, acceptCall = { _ -> },
acceptFeature = { _, _, _ -> }, acceptFeature = { _, _, _ -> },
openDirectChat = { _ -> }, openDirectChat = { _ -> },
@@ -1364,10 +1435,12 @@ fun PreviewGroupChatLayout() {
setReaction = { _, _, _, _ -> }, setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> }, showItemDetails = { _, _ -> },
addMembers = { _ -> }, addMembers = { _ -> },
openGroupLink = {},
markRead = { _, _ -> }, markRead = { _, _ -> },
changeNtfsState = { _, _ -> }, changeNtfsState = { _, _ -> },
onSearchValueChanged = {}, onSearchValueChanged = {},
onComposed = {}, onComposed = {},
developerTools = false,
) )
} }
} }
@@ -159,6 +159,17 @@ expect fun AttachmentSelection(
processPickedMedia: (List<URI>, String?) -> Unit processPickedMedia: (List<URI>, String?) -> Unit
) )
fun MutableState<ComposeState>.onFilesAttached(uris: List<URI>) {
val groups = uris.groupBy { isImage(it) }
val images = groups[true] ?: emptyList()
val files = groups[false] ?: emptyList()
if (images.isNotEmpty()) {
CoroutineScope(Dispatchers.IO).launch { processPickedMedia(images, null) }
} else if (files.isNotEmpty()) {
processPickedFile(uris.first(), null)
}
}
fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) { fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
if (uri != null) { if (uri != null) {
val fileSize = getFileSize(uri) val fileSize = getFileSize(uri)
@@ -313,8 +324,26 @@ fun ComposeView(
} }
fun deleteUnusedFiles() { fun deleteUnusedFiles() {
chatModel.filesToDelete.forEach { it.delete() } val shared = chatModel.sharedContent.value
chatModel.filesToDelete.clear() if (shared == null) {
chatModel.filesToDelete.forEach { it.delete() }
chatModel.filesToDelete.clear()
} else {
val sharedPaths = when (shared) {
is SharedContent.Media -> shared.uris.map { it.toString() }
is SharedContent.File -> listOf(shared.uri.toString())
is SharedContent.Text -> emptyList()
}
// When sharing a file and pasting it in SimpleX itself, the file shouldn't be deleted before sending or before leaving the chat after sharing
chatModel.filesToDelete.removeAll { file ->
if (sharedPaths.any { it.endsWith(file.name) }) {
false
} else {
file.delete()
true
}
}
}
} }
suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? { suspend fun send(cInfo: ChatInfo, mc: MsgContent, quoted: Long?, file: CryptoFile? = null, live: Boolean = false, ttl: Int?): ChatItem? {
@@ -816,6 +845,7 @@ fun ComposeView(
chatModel.removeLiveDummy() chatModel.removeLiveDummy()
}, },
editPrevMessage = ::editPrevMessage, editPrevMessage = ::editPrevMessage,
onFilesPasted = { composeState.onFilesAttached(it) },
onMessageChange = ::onMessageChange, onMessageChange = ::onMessageChange,
textStyle = textStyle textStyle = textStyle
) )
@@ -29,6 +29,8 @@ import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource import dev.icerock.moko.resources.compose.stringResource
import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.* import kotlinx.coroutines.*
import java.io.File
import java.net.URI
@Composable @Composable
fun SendMsgView( fun SendMsgView(
@@ -52,6 +54,7 @@ fun SendMsgView(
updateLiveMessage: (suspend () -> Unit)? = null, updateLiveMessage: (suspend () -> Unit)? = null,
cancelLiveMessage: (() -> Unit)? = null, cancelLiveMessage: (() -> Unit)? = null,
editPrevMessage: () -> Unit, editPrevMessage: () -> Unit,
onFilesPasted: (List<URI>) -> Unit,
onMessageChange: (String) -> Unit, onMessageChange: (String) -> Unit,
textStyle: MutableState<TextStyle> textStyle: MutableState<TextStyle>
) { ) {
@@ -79,7 +82,7 @@ fun SendMsgView(
val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && val showVoiceButton = !nextSendGrpInv && cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing &&
cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started)
val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } val showDeleteTextButton = rememberSaveable { mutableStateOf(false) }
PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage) { PlatformTextField(composeState, sendMsgEnabled, textStyle, showDeleteTextButton, userIsObserver, onMessageChange, editPrevMessage, onFilesPasted) {
if (!cs.inProgress) { if (!cs.inProgress) {
sendMessage(null) sendMessage(null)
} }
@@ -612,6 +615,7 @@ fun PreviewSendMsgView() {
sendMessage = {}, sendMessage = {},
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }
@@ -645,6 +649,7 @@ fun PreviewSendMsgViewEditing() {
sendMessage = {}, sendMessage = {},
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }
@@ -678,6 +683,7 @@ fun PreviewSendMsgViewInProgress() {
sendMessage = {}, sendMessage = {},
editPrevMessage = {}, editPrevMessage = {},
onMessageChange = { _ -> }, onMessageChange = { _ -> },
onFilesPasted = {},
textStyle = textStyle textStyle = textStyle
) )
} }
@@ -19,11 +19,19 @@ import chat.simplex.common.model.*
import chat.simplex.common.platform.shareText import chat.simplex.common.platform.shareText
import chat.simplex.common.ui.theme.* import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.* import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.QRCode import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR import chat.simplex.res.MR
@Composable @Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit) { fun GroupLinkView(
chatModel: ChatModel,
groupInfo: GroupInfo,
connReqContact: String?,
memberRole: GroupMemberRole?,
onGroupLinkUpdated: ((Pair<String, GroupMemberRole>?) -> Unit)?,
creatingGroup: Boolean = false,
close: (() -> Unit)? = null
) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) } var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) } val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
var creatingLink by rememberSaveable { mutableStateOf(false) } var creatingLink by rememberSaveable { mutableStateOf(false) }
@@ -34,7 +42,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) { if (link != null) {
groupLink = link.first groupLink = link.first
groupLinkMemberRole.value = link.second groupLinkMemberRole.value = link.second
onGroupLinkUpdated(link) onGroupLinkUpdated?.invoke(link)
} }
creatingLink = false creatingLink = false
} }
@@ -44,14 +52,12 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
createLink() createLink()
} }
} }
val clipboard = LocalClipboardManager.current
GroupLinkLayout( GroupLinkLayout(
groupLink = groupLink, groupLink = groupLink,
groupInfo, groupInfo,
groupLinkMemberRole, groupLinkMemberRole,
creatingLink, creatingLink,
createLink = ::createLink, createLink = ::createLink,
share = { clipboard.shareText(groupLink ?: return@GroupLinkLayout) },
updateLink = { updateLink = {
val role = groupLinkMemberRole.value val role = groupLinkMemberRole.value
if (role != null) { if (role != null) {
@@ -60,7 +66,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) { if (link != null) {
groupLink = link.first groupLink = link.first
groupLinkMemberRole.value = link.second groupLinkMemberRole.value = link.second
onGroupLinkUpdated(link) onGroupLinkUpdated?.invoke(link)
} }
} }
} }
@@ -75,13 +81,15 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId) val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) { if (r) {
groupLink = null groupLink = null
onGroupLinkUpdated(null) onGroupLinkUpdated?.invoke(null)
} }
} }
}, },
destructive = true, destructive = true,
) )
} },
creatingGroup = creatingGroup,
close = close
) )
if (creatingLink) { if (creatingLink) {
ProgressIndicator() ProgressIndicator()
@@ -95,10 +103,20 @@ fun GroupLinkLayout(
groupLinkMemberRole: MutableState<GroupMemberRole?>, groupLinkMemberRole: MutableState<GroupMemberRole?>,
creatingLink: Boolean, creatingLink: Boolean,
createLink: () -> Unit, createLink: () -> Unit,
share: () -> Unit,
updateLink: () -> Unit, updateLink: () -> Unit,
deleteLink: () -> Unit deleteLink: () -> Unit,
creatingGroup: Boolean = false,
close: (() -> Unit)? = null
) { ) {
@Composable
fun ContinueButton(close: () -> Unit) {
SimpleButton(
stringResource(MR.strings.continue_to_next_step),
icon = painterResource(MR.images.ic_check),
click = close
)
}
Column( Column(
Modifier Modifier
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
@@ -115,7 +133,16 @@ fun GroupLinkLayout(
verticalArrangement = Arrangement.SpaceEvenly verticalArrangement = Arrangement.SpaceEvenly
) { ) {
if (groupLink == null) { if (groupLink == null) {
SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink) Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
) {
SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink)
if (creatingGroup && close != null) {
ContinueButton(close)
}
}
} else { } else {
RoleSelectionRow(groupInfo, groupLinkMemberRole) RoleSelectionRow(groupInfo, groupLinkMemberRole)
var initialLaunch by remember { mutableStateOf(true) } var initialLaunch by remember { mutableStateOf(true) }
@@ -125,23 +152,28 @@ fun GroupLinkLayout(
} }
initialLaunch = false initialLaunch = false
} }
QRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING)) SimpleXLinkQRCode(groupLink, Modifier.aspectRatio(1f).padding(horizontal = DEFAULT_PADDING))
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp) modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
) { ) {
val clipboard = LocalClipboardManager.current
SimpleButton( SimpleButton(
stringResource(MR.strings.share_link), stringResource(MR.strings.share_link),
icon = painterResource(MR.images.ic_share), icon = painterResource(MR.images.ic_share),
click = share click = { clipboard.shareText(simplexChatLink(groupLink)) }
)
SimpleButton(
stringResource(MR.strings.delete_link),
icon = painterResource(MR.images.ic_delete),
color = Color.Red,
click = deleteLink
) )
if (creatingGroup && close != null) {
ContinueButton(close)
} else {
SimpleButton(
stringResource(MR.strings.delete_link),
icon = painterResource(MR.images.ic_delete),
color = Color.Red,
click = deleteLink
)
}
} }
} }
} }

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