Compare commits

...

122 commits

Author SHA1 Message Date
spaced4ndy
3d22b738d8
core: fix change connection user (#5992)
* core: fix change connection user

* plans
2025-06-16 22:38:02 +01:00
Evgeny Poberezkin
c08189108e
6.3.6: ios 282, android 295, desktop 106 2025-06-14 20:12:19 +01:00
Evgeny Poberezkin
442d9afc4b
android: remove Contribute link from Android bundle 2025-06-14 19:26:46 +01:00
Evgeny Poberezkin
a593557c21
core: 6.3.6.0 (simplexmq 6.4.0.3.1) 2025-06-14 14:46:08 +01:00
Evgeny
07abe24e18
core: make decoding for short link data forward compatible (#5989)
* core: make decoding for short link data forward compatible

* simplexmq
2025-06-14 14:17:34 +01:00
Evgeny Poberezkin
5f6595dda9
6.3.5: ios 280, android 292, desktop 104 2025-06-09 09:32:32 +01:00
Evgeny Poberezkin
6fdd50efb9
core: 6.3.5.0 2025-06-08 18:28:26 +01:00
Evgeny
50dfda6c09
core: fix deletion queries for PostgreSQL client (#5969)
* core: fix deletion queries for PostgreSQL client

* disable test in posrgres

* plan
2025-06-08 18:27:42 +01:00
Evgeny Poberezkin
ea1a81fcac
core: 6.3.4.2 (simplexmq 6.4.0.3) 2025-06-06 12:26:46 +01:00
Evgeny
cf0639bf28
website: add Whonix to reviews (#5966) 2025-06-05 21:05:16 +01:00
Evgeny
7b362ff655
ui: label in compose when user cannot send messages (#5922)
* ui: label in compose when user cannot send messages

* gray buttons when user cannot send messages

* improve

* kotlin

* fix order

* fix alert

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2025-05-19 14:50:33 +00:00
Evgeny
26e5742354
ios: fix swipe in members list for iOS 15 (#5914)
* ios: fix swipe in members list for iOS 15

* refactor
2025-05-15 14:58:40 +01:00
Evgeny
5dd89fe127
ios: fix swipe on iOS 15, fix onboarding layout on iOS 15 and small screens (#5913)
* ios: fix onboarding layout issues on iOS 15 and small screens

* fix swipe on iOS 15
2025-05-15 14:25:46 +01:00
sh
a36a6d44db
flatpak: update metainfo (#5899)
* flatpak: update metainfo

* flatpak: rewrite metainfo
2025-05-14 10:55:03 +01:00
Evgeny Poberezkin
8af3cb935e
6.3.4: android 288, desktop 101 2025-05-13 18:53:37 +01:00
Evgeny Poberezkin
dc35e5f765
android, desktop: fix sending reports 2025-05-13 18:19:22 +01:00
Evgeny Poberezkin
9b4908c370
6.3.4: ios 277, android 287, desktop 100 2025-05-12 19:34:06 +01:00
Evgeny Poberezkin
9098e22d4b
core: 6.3.4.1 2025-05-12 16:58:49 +01:00
spaced4ndy
1f8609a31f
core: make member admission forwards compatible (#5893)
* core: make member admission forwards compatible

* cabal file

* schema

* plans

* inserts

* plans
2025-05-12 16:57:20 +01:00
Evgeny
348961576b
website: translations (#5896)
Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

Co-authored-by: summoner001 <summoner@vivaldi.net>
2025-05-12 16:36:18 +01:00
Evgeny
a5a9d4f7d5
website: translations (#5892)
* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 78.5% (202 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 78.5% (202 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 78.5% (202 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 78.5% (202 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

---------

Co-authored-by: Bezruchenko Simon <worcposj44@gmail.com>
Co-authored-by: dns <dnstxqet@users.noreply.hosted.weblate.org>
Co-authored-by: 4 Bi 5aYzVk 93FCVjWLWxh44XH3984teVSfjwFYmUGUrbvnHwGirk9 <userfifteen.seventeen@mailfence.com>
Co-authored-by: summoner001 <summoner@vivaldi.net>
2025-05-12 16:27:42 +01:00
Evgeny Poberezkin
c822fa53f6
ios: 6.3.4 (build 276) 2025-05-12 16:23:44 +01:00
spaced4ndy
c0b9a0e094
android, desktop: narrow condition for showing reported count toolbar (to avoid showing it to regular members who received reports due to a bug in older version) (#5894) 2025-05-12 15:40:36 +01:00
Evgeny
9e60ce7a60
ui: translations (#5891)
* Translated using Weblate (Italian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2058 of 2058 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 85.6% (1762 of 2058 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 73.6% (1735 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hant/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 56.5% (1164 of 2058 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hant/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2058 of 2058 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2058 of 2058 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2058 of 2058 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2054 of 2054 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 94.8% (1949 of 2054 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/

* export/import localizations

---------

Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: dns <dnstxqet@users.noreply.hosted.weblate.org>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: mlanp <github@lang.xyz>
2025-05-12 13:33:49 +01:00
Evgeny
bb2e7baaa8
ios: fix taps on reactions, member profile images, selecting items, icons to navigate to found and forwarded items (Xcode 16 regressions) (#5890) 2025-05-12 12:59:03 +01:00
Evgeny
2a43a02af3
core, ui: support trailing punctuation for mentions, URIs (also support domains), and email addresses (#5888)
* core: improve markdown parser for mentions, URIs, and email addresses

* ui
2025-05-12 11:22:35 +01:00
Evgeny
e1aa32952e
ios: unblur media on tap, open/play on the second tap; handle link preview errors (#5886)
* ios: unblur media on tap, open/play on the second tap (Xcode 16 regression)

* disable link preview spinner on link loading error
2025-05-11 15:42:09 +01:00
Evgeny
8d54acef92
ios: only handle taps on messages with links or secrets, use image for secret markdown (#5885)
* ios: use image for secret markdown

* remove unnecessary ViewBuilders
2025-05-11 14:15:14 +01:00
Evgeny Poberezkin
d338696035
ios: 6.3.4 (build 275) 2025-05-10 17:23:53 +01:00
Evgeny Poberezkin
5b7f3fdd78
ios: export localizations 2025-05-10 16:46:54 +01:00
Evgeny
4b42a19ccb
ios: fix XCode 16 regressions (tap not working on files, quotes, images, voice messages, etc.), open link previews on tap (#5880)
* ios: fix XCode 16 regressions (tap not working on files, quotes, images, voice messages, etc.), open link previews on tap

* fix voice recording

* fix video, accepting calls from chat, preference toggles in chat

* WIP message and meta

* handle links in attributed strings

* custom attribute for links to prevent race conditions with default tap handler
2025-05-10 14:37:45 +01:00
Evgeny Poberezkin
5f56f61c36
ios: v6.3.4 build 274 (internal release) 2025-05-07 12:58:25 +01:00
Evgeny
f49c51ae16
website: translations, readme: ZEC address (#5875)
* Translated using Weblate (German)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/

* Translated using Weblate (Arabic)

Currently translated at 91.4% (235 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/

* Translated using Weblate (Hungarian)

Currently translated at 92.2% (237 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/

* Translated using Weblate (Arabic)

Currently translated at 91.4% (235 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hans/

* Translated using Weblate (Hungarian)

Currently translated at 92.2% (237 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ar/

* Translated using Weblate (Italian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/it/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (257 of 257 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* fix

* ZEC address

---------

Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Muhammad <muhammad.aem@outlook.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
2025-05-07 11:27:10 +01:00
Evgeny
ecb4a36045
ui: translations (#5874)
* Translated using Weblate (German)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2056 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2056 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2056 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Czech)

Currently translated at 99.9% (2351 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/

* Translated using Weblate (Czech)

Currently translated at 56.6% (1165 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/cs/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2056 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/

* Translated using Weblate (Arabic)

Currently translated at 35.5% (730 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2056 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Czech)

Currently translated at 99.9% (2354 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2056 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2056 of 2056 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (2356 of 2356 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/

* process localizations

---------

Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org>
Co-authored-by: zenobit <zenobit@disroot.org>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: Muhammad <muhammad.aem@outlook.com>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: fran secs <fransecs@gmail.com>
Co-authored-by: Bezruchenko Simon <worcposj44@gmail.com>
Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com>
Co-authored-by: Rafi <rafimuhmad90@protonmail.com>
2025-05-07 10:34:42 +01:00
Evgeny Poberezkin
73fe6827b2 ios: update core library 2025-05-07 09:54:15 +01:00
Evgeny Poberezkin
9329bf6144
core: 6.3.4.0 (simplexmq 6.4.0.2) 2025-05-07 08:14:11 +01:00
Evgeny
05de019ecd
ios: deliver notifications instantly when server has no more messages and better concurrency (#5872)
* core: return error and message absence when getting notifications

* ios: do not wait for notification messages when server says "no"

* do not postpone some notification events, comments

* refactor

* simplexmq (mapM)

* simplexmq (release lock)

* ios: inline, more aggressive GHC RTC settings for garbage collection

* simplexmq

* corrections

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* refactor ntf delivery

* ios: 6.3.4 (build 274)

* simplexmq (fix updating last ts)

* improve notification for multiple messages

* simplexmq

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2025-05-07 07:33:00 +01:00
Evgeny
24b0f0290b
core: pass event and response error without dedicated constructor (#5869)
* core: pass event and response error without dedicated constructor

* ios: WIP

* android, desktop: update UI for new API

* ios: fix parser

* fix showing invalid chats

* fix mobile api tests

* ios: split ChatResponse to 3 enums, decode API results on the same thread

* tweak types

* remove throws

* rename
2025-05-05 11:51:22 +01:00
Evgeny
a0d1cca389
core: split response to two types, to improve iOS parsing memory usage (#5867)
* core: split response to two types, to improve iOS parsing memory usage

* ios: split core events to separate types

* comment

* limit more events to CLI

* fix parser

* simplemq
2025-05-04 22:14:36 +01:00
Evgeny
f5c706f2dd
ios: remove types used only in the app from the framework (#5866)
* ios: remove types used only in the app from the framework

* move more types

* comment
2025-05-02 12:27:08 +01:00
Evgeny
e7a4611be9
ios: deliver notifications even if iOS fails to fire expiration notice, prevent repeat delivery of stale notifications (#5861)
* ios: deliver notification when iOS fails to fire expiration notice for NSE

* update core api

* update ui

* sha256map.nix

* do not enable background processes in maintenance mode

* fix ios

* fix parser

* ios: fix command

* compatible parser for connection ID

* log

* pass DB queue ID

* simplexmq

* query plans

* fix broadcast bot test
2025-05-02 12:23:05 +01:00
spaced4ndy
38b8e0cee6
ios: refactor chat state (remove chatItemsChangesListener) (#5858) 2025-04-29 16:27:19 +00:00
Evgeny
ca49167ec6
directory service: fix deleting group registration (#5856) 2025-04-27 15:55:49 +01:00
Evgeny
7cac164b84
core: use /feed command in broadcast bot (#5854) 2025-04-27 12:38:51 +01:00
Evgeny
6390263370
android, desktop: additional information about database errors in console (#5853) 2025-04-26 16:02:29 +01:00
Evgeny
7b11d8514a
core, ui: option to use web port by default for preset servers only (#5847)
* core: option to use web port by default for preset servers only

* ui

* refactor

* simplexmq
2025-04-25 11:17:27 +01:00
Evgeny
d53c13f8be
docs: dependencies (#5850) 2025-04-25 11:17:09 +01:00
Evgeny
623a46e418
website: new languages, update some texts (#5849)
* website: new languages, update some texts

* update
2025-04-25 11:16:41 +01:00
sh
3625233931
flatpak: update metainfo (#5846) 2025-04-24 09:11:43 +01:00
Evgeny Poberezkin
83b3d631f5
6.3.3: ios 273, android 285, desktop 99 2025-04-23 18:14:43 +01:00
Evgeny Poberezkin
3257b60b70
core: 6.3.3.1 2025-04-23 13:27:58 +01:00
sh
5351fa68d0
ci: switch to sha256 and skip 8.10.7 on release (#5837)
* ci: skip 8.10.7 on release

* ci: switch to sha256

* script/reproduce-builds: make it executable

* scripts/reproduce-builds: rename to simplex-chat-reproduce-builds

* ci: bump actions

* ci: 20.04 is deprecated

* scripts/reproduce-builds: remove Ubuntu 20.04

* docs: adjust reproduce script

* ci: skip 8.10.7 in stable or release for Linux

* ci: really skup 8.10.7 in stable or release

* ci: remove useless linux checks

* ci: remove timeout from mac tests

* ci: fix action names

* ci: setup swap for 8.10.7

* ci: bump swap to 30gb

* ci: simplify

* ci: 10 -> 3 retries

* ci: retry only in stable or release
2025-04-23 13:27:30 +01:00
Evgeny Poberezkin
96b962809f
core: fix connecting via short links 2025-04-23 13:09:16 +01:00
Evgeny
52e2af6e32
ui: translations (#5843)
* Translated using Weblate (Italian)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (Hebrew)

Currently translated at 81.9% (1923 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hebrew)

Currently translated at 82.6% (1938 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/

* Translated using Weblate (Hebrew)

Currently translated at 85.8% (2013 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/

* Translated using Weblate (German)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (Hebrew)

Currently translated at 86.5% (2031 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/

* Translated using Weblate (Hebrew)

Currently translated at 86.7% (2034 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/

* Translated using Weblate (Hebrew)

Currently translated at 86.7% (2035 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Czech)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/

* Translated using Weblate (Russian)

Currently translated at 97.4% (1998 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (Hebrew)

Currently translated at 86.7% (2035 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/he/

* Translated using Weblate (Indonesian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/

* Translated using Weblate (French)

Currently translated at 96.0% (1971 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (German)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/

* Translated using Weblate (German)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2352 of 2352 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2051 of 2051 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* process localizations

* update ru translations

* correct ru case

* export

---------

Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: מילקי צבעוני <c0t3@users.noreply.hosted.weblate.org>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: gacarel <gacarel657@bariswc.com>
Co-authored-by: Bezruchenko Simon <worcposj44@gmail.com>
Co-authored-by: zenobit <zenobit@disroot.org>
Co-authored-by: thedmdim <thedmdim@gmail.com>
Co-authored-by: Rafi <rafimuhmad90@protonmail.com>
Co-authored-by: khalidbelk <khalid.belkassmi-el-hafi@epitech.eu>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org>
Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com>
Co-authored-by: fran secs <fransecs@gmail.com>
Co-authored-by: M1K4 <oomikaoo@gmail.com>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
2025-04-23 10:53:55 +01:00
Evgeny
a6fd5ce902
website: translations (#5845)
* Translated using Weblate (Hebrew)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/

* Translated using Weblate (Hebrew)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/he/

* Translated using Weblate (German)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/de/

---------

Co-authored-by: מילקי צבעוני <c0t3@users.noreply.hosted.weblate.org>
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: summoner001 <summoner@vivaldi.net>
2025-04-23 10:30:37 +01:00
Evgeny Poberezkin
b637d370f3
core: 6.3.3.0 (simplexmq 6.4.0.1) 2025-04-23 08:33:17 +01:00
Evgeny Poberezkin
82f9fecccf
android, desktop: enable reactions 😂 and 2025-04-16 19:22:41 +01:00
spaced4ndy
0f3e546e36
kotlin: refactor chat contexts 2 (null secondary context, pass context instead of content tag, straighten chat state code) (#5830) 2025-04-15 13:50:06 +00:00
sh
f52d06af3a
flatpak: update metainfo (#5836)
* flatpak: update metainfo

* include previous changes
2025-04-15 10:24:24 +01:00
sh
df99ed495c
ci/docker: use Java Corretto (#5832) 2025-04-15 10:02:50 +01:00
Evgeny
45e395d35a
core, ui: short connection links with stored data (#5824)
* core, ui: optionally use short links (#5799)

* core: optionally use short links

* update test

* update simplexmq, short group links

* fix query

* fix parser for _connect

* ios: use short links

* shorten links to remove fingerprint and onion hosts from known servers

* fix parser

* tests

* nix

* update query plans

* update simplexmq, simplex: schema for short links

* simplexmq

* update ios

* fix short links in ios

* android: use short links

* fix short group links, test short link connection plans

* core: fix connection plan to recognize own short links

* update simplexmq

* space

* all tests

* relative symlinks in simplexmq to fix windows build

* core: improve connection plan for short links (#5825)

* core: improve connection plan for short links

* improve connection plans

* update UI

* update simplexmq

* ios: add preset server domains to entitlements, add short link paths to .well-known/apple-app-site-association

* update simplexmq

* fix group short link in iOS, fix simplex:/ scheme saved to database or used for connection plans

* update simplexmq

* ios: delay opening URI from outside until the app is started

* update simplexmq
2025-04-14 21:25:32 +01:00
spaced4ndy
38c2529d8b
kotlin: refactor chat contexts 1 (remove functions creating indirection) (#5827)
* kotlin: refactor chat contexts 1

* remove withChats

* comment

* remove withReportChatsIfOpen

* remove comment

* fix desktop
2025-04-14 17:01:22 +01:00
Evgeny Poberezkin
14d9240995
docs: correction to command 2025-04-13 10:50:06 +01:00
Evgeny Poberezkin
eae281df60
6.3.2: ios 272, android 283, desktop 98 2025-04-12 21:53:53 +01:00
Evgeny Poberezkin
8766891124
core: 6.3.2.0 (simplexmq 6.3.2.0) 2025-04-12 20:07:27 +01:00
Evgeny
090f576b65
directory: allow admins deleting groups and registering groups with the same name as deleted one; /help commands; better support of other group owners; support link encoding and version changes (#5829)
* allow admins deleting groups from directory and registering groups with same name as deleted one; /help commands

* support profile changes by other owners, with/without connection to directory

* profile check will succeed when group link encoding or versions change, but the link queues remain the same
2025-04-12 19:34:30 +01:00
sh
48b1ef764b
ci: reproducible builds/refactor (#5808)
* ci: reproducible builds/refactor

* ci: fix mac desktop upload

* ci: docker shell abort on error

* scripts: add reproduce script

* ci: add new reproduce workflow

* scripts/reproduce-builds: change repo back to official
2025-04-11 23:19:24 +01:00
Evgeny
3fb09d3def
core: fix types for PostgreSQL database (#5800)
* core: fix types for PostgreSQL database

* option to create schema

* use action forks
2025-04-03 16:27:40 +01:00
Evgeny Poberezkin
e7f8533112 ios: v6.3.1, build 271 using XCode 15 2025-04-03 10:55:54 +01:00
sh
d45ecff13a
flatpak: update metainfo (#5794) 2025-04-01 12:44:22 +01:00
Evgeny Poberezkin
4b6d1d4585
6.3.1: ios 270, android 281, desktop 97 2025-03-31 20:09:46 +01:00
Evgeny
66273790e6
website: translations (#5792)
* Translated using Weblate (Russian)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/hu/

* Translated using Weblate (Japanese)

Currently translated at 98.8% (255 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ja/

* Translated using Weblate (French)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/es/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/pt_BR/

* Translated using Weblate (Chinese (Traditional Han script))

Currently translated at 10.8% (28 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/zh_Hant/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ja/

---------

Co-authored-by: noname <zhuk2@duck.com>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: Miyu Sakatsuki <miyu-sakatsuki@outlook.jp>
Co-authored-by: Farias França <nathanfariasfranca@gmail.com>
2025-03-31 19:38:54 +01:00
Evgeny
a32ed2ec1f
ui: translations (#5791)
* Translated using Weblate (Spanish)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Japanese)

Currently translated at 82.8% (1939 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/

* Translated using Weblate (Dutch)

Currently translated at 99.3% (2326 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/

* Translated using Weblate (Dutch)

Currently translated at 99.8% (2051 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.1% (2252 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.8% (2267 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 96.8% (2267 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/

* Translated using Weblate (Japanese)

Currently translated at 83.2% (1950 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.1% (2298 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Japanese)

Currently translated at 83.3% (1951 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ja/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.1% (2298 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Czech)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/

* Translated using Weblate (Vietnamese)

Currently translated at 99.7% (2334 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Indonesian)

Currently translated at 99.1% (2321 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/

* Translated using Weblate (Indonesian)

Currently translated at 99.7% (2334 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/id/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/nl/

* Translated using Weblate (Dutch)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/nl/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/

* Translated using Weblate (Korean)

Currently translated at 21.9% (451 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ko/

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Arabic)

Currently translated at 30.8% (633 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (Russian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (2341 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.9% (2340 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Turkish)

Currently translated at 93.1% (2181 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/tr/

* Translated using Weblate (Russian)

Currently translated at 96.6% (1987 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (French)

Currently translated at 98.6% (2309 of 2341 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/fr/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 84.3% (1734 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/zh_Hans/

* Translated using Weblate (Hungarian)

Currently translated at 99.7% (2341 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Spanish)

Currently translated at 99.8% (2343 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/

* Translated using Weblate (Catalan)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ca/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Vietnamese)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/vi/

* Translated using Weblate (Spanish)

Currently translated at 99.8% (2343 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2055 of 2055 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Russian)

Currently translated at 100.0% (2346 of 2346 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* export/import localizations

* add translations for onboarding

* translation

---------

Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org>
Co-authored-by: fran secs <fransecs@gmail.com>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: k-kozika <mail@kozika.jp>
Co-authored-by: M1K4 <oomikaoo@gmail.com>
Co-authored-by: João Moreira <jyj@gmx.ie>
Co-authored-by: Igor Julliano <igor.julliano2@gmail.com>
Co-authored-by: Miyu Sakatsuki <miyu-sakatsuki@outlook.jp>
Co-authored-by: zenobit <zenobit@disroot.org>
Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com>
Co-authored-by: Rafi <rafimuhmad90@protonmail.com>
Co-authored-by: dtalens <databio@gmail.com>
Co-authored-by: jaeone <jaeone22@proton.me>
Co-authored-by: Muhammad <muhammad.aem@outlook.com>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Volkan Yıldırım <volkan.y@posteo.com>
Co-authored-by: Near <roma.baldin@gmail.com>
Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com>
Co-authored-by: Ross Li <ross89223@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: Mihai Pantazi <smr2.1112.pantazim@gmail.com>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
2025-03-31 19:29:45 +01:00
Evgeny Poberezkin
af56b3fed0
ios: update core library 2025-03-31 17:27:12 +01:00
Evgeny Poberezkin
7c1d900e1f
core: 6.3.1.1 2025-03-29 20:47:15 +00:00
Evgeny
27f2926aed
directory: joining groups with enabled captcha screening and observer role (#5784)
* directory: joining groups with enabled captcha screen (test)

* fix directory, test

* query plans
2025-03-28 18:48:54 +00:00
spaced4ndy
4443786474
ui: move operators selection to sheet on onboarding (#5783)
* ios: show updated conditions always on what's new screen

* rework onboarding

* update text

* android whatsnew

* android wip

* layout

* improve what's new layout

* remove

* fix desktop

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-28 15:37:39 +00:00
Evgeny
f8fddb1daf
docs: update server doc about reproducing builds (#5779) 2025-03-25 12:52:49 +00:00
Narasimha-sc
99dcaa34ba
readme: update SimpleX users group link (#5772) 2025-03-25 12:32:12 +00:00
Evgeny Poberezkin
9f853e2e84
core: 6.3.1.0 (simplexmq 6.3.1.0) 2025-03-22 14:20:29 +00:00
Evgeny
15742aee30
ios: XCode 16 workaround to prevent stack overflow (#5771)
* ios: Workaround for stackoverflow with Xcode 16

- Increased stack size to 4MiB
- Fix: https://github.com/simplex-chat/simplex-chat/issues/4837

* Remove Main Thread Stack Size Linker Setting

Removed the linker setting for the main thread stack size as the main thread is no longer used.

* Set Thread Stack Size to 2MiB

Set the thread stack size to 2MiB. In my environment, 992KiB worked fine, so increasing the size to more than double should provide sufficient margin.

* ios: moving content up when setting emoji on the first message (#5766)

* simplify

---------

Co-authored-by: ISHIHARA Kazuto <acevif@kubkul.in>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2025-03-21 11:49:59 +00:00
Stanislav Dmitrenko
6020c6010d
ios: moving content up when setting emoji on the first message (#5766) 2025-03-20 23:06:52 +00:00
Stanislav Dmitrenko
cd20dc0a04
android, desktop: enhancements to floating buttons (#5763)
* android, desktop: enhancements to floating buttons

* size

* size
2025-03-19 15:11:53 +00:00
Stanislav Dmitrenko
6b75f61537
android, desktop: scrolling improvements (#5753)
* android, desktop: scrolling improvements

* more changes

* fixes

* search

* fix concurrency

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-19 15:07:05 +00:00
Stanislav Dmitrenko
6e7df9c72d
android, desktop: menu near top floating button (#5764) 2025-03-19 15:06:08 +00:00
Evgeny
6556e09a33
core: update simplexmq to support PostgreSQL servers (#5760)
* core: update simplexmq to support postgres

* update simplexmq

* update ios
2025-03-19 07:16:31 +00:00
Stanislav Dmitrenko
745372dd7a
android, desktop: open links from notes (#5761) 2025-03-18 21:20:11 +00:00
Stanislav Dmitrenko
e58d09ce78
android, desktop: fix negative content offset on some Android devices (#5752) 2025-03-18 21:18:58 +00:00
Evgeny
b8e2e71a60
core: exclude CLI modules from client library (#5758)
* core: exclude CLI modules from client library

* client_library flag in nix builds

* use client_library in builds, update iOS library
2025-03-16 19:30:31 +00:00
Stanislav Dmitrenko
ae24da090c
android, desktop: fix crash on very long quoted message (#5751) 2025-03-16 16:48:36 +00:00
Stanislav Dmitrenko
364aa667ad
ios: scrolling improvements (#5746)
* ios: scrolling improvements

* changes

* fixes

* fix

* private

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-13 22:36:45 +00:00
Evgeny
45c7c6bc6e
directory: use lowercase letters in captcha, accept any case for same-looking letters (#5744) 2025-03-12 10:30:04 +00:00
Evgeny
aba09939e2
directory: more permissive captcha rules (#5741) 2025-03-11 10:32:02 +00:00
sh
5050d60825
flatpak: update metainfo (#5740) 2025-03-11 08:37:48 +00:00
spaced4ndy
2317cee3eb
desktop: fix postgres migration (#5739) 2025-03-10 14:54:55 +04:00
Evgeny
16cf91902c
blog: update v6.3 blog post (#5735) 2025-03-09 20:18:58 +00:00
Evgeny Poberezkin
ed625347bd
ios: v6.3, build 269 2025-03-09 16:08:49 +00:00
Stanislav Dmitrenko
4bd95c8e4e
ios: fix random crashes in chat on iOS 18 (#5734) 2025-03-09 11:22:47 +00:00
Evgeny
9dfa68bf57
blog: update v6.3 release post (#5733)
* blog: update v6.3 release post

* update post, server page

* update

* headers
2025-03-08 23:28:33 +00:00
sh
3188d9f087
docs: add reproducibility section (#5732) 2025-03-08 20:53:27 +00:00
Evgeny Poberezkin
89dddab060
6.3: ios 268, android 279, desktop 96 2025-03-07 18:18:43 +00:00
Stanislav Dmitrenko
f31372a771
android, desktop: fix link preview (#5725)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-07 17:10:57 +00:00
Stanislav Dmitrenko
3412ceba01
android, desktop: fix group members duplicates (#5727)
* android, desktop: fix group members duplicates

* optimization

* use groupMemberId as key

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-07 16:49:55 +00:00
Stanislav Dmitrenko
27f63dafaa
ui: option to remove messages of removed members (#5717)
* ui: removing messages of removed members

* android

* fix android

* fix ios and refactor

* refactor android

* update

* update2

* remove ts

* android new logic

* refactor

* remove spaghetti

* if

* android

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-07 15:08:00 +00:00
Evgeny Poberezkin
e2d488266c
core: 6.3.0.8 (simplexmq 6.3.0.8) 2025-03-07 14:59:00 +00:00
spaced4ndy
430e212a9e
core: name limit (#5724)
* core: name limit

* ios

* trim spaces, test
2025-03-07 14:38:06 +00:00
spaced4ndy
47adbe2813
ui: fix strings, update translations (#5718)
* ios: fix strings

* update translations

* update report ru translations

* remove unnecessary localizations

* update ru translations

* update android translations

* import translations

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-07 12:50:44 +00:00
Stanislav Dmitrenko
cc2a45bdaf
android, desktop: expand moderated messages (#5722) 2025-03-07 11:16:56 +00:00
Stanislav Dmitrenko
ad4adf66ec
ios: fix small scroll on new message (#5721)
* ios: fix small scroll on new message

* added inset in calculation of offset
2025-03-07 09:19:37 +00:00
Evgeny
f53b21f8c6
ui: translations (#5719)
* Translated using Weblate (Portuguese)

Currently translated at 40.5% (946 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2334 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/it/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2334 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (German)

Currently translated at 100.0% (2334 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (German)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/de/

* Translated using Weblate (Czech)

Currently translated at 99.7% (2328 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2334 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Czech)

Currently translated at 99.8% (2331 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/

* Translated using Weblate (Czech)

Currently translated at 100.0% (2334 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/cs/

* Translated using Weblate (Arabic)

Currently translated at 27.2% (563 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2334 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Russian)

Currently translated at 98.6% (2302 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (2334 of 2334 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Italian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/it/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Arabic)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ar/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/zh_Hans/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.5% (2167 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 92.5% (2168 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/pt_BR/

* Translated using Weblate (Arabic)

Currently translated at 28.7% (593 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/

* Translated using Weblate (Arabic)

Currently translated at 29.5% (610 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ar/

* Translated using Weblate (German)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/de/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Russian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 96.9% (2002 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Russian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/ru/

* Translated using Weblate (Russian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/ru/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Spanish)

Currently translated at 99.8% (2339 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 99.8% (2061 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/hu/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2342 of 2342 strings)

Translation: SimpleX Chat/SimpleX Chat Android
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/android/es/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/es/

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (2065 of 2065 strings)

Translation: SimpleX Chat/SimpleX Chat iOS
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/ios/hu/

* process localizations

---------

Co-authored-by: Anonymous <noreply@weblate.org>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: zenobit <zenobit@disroot.org>
Co-authored-by: Gholamy-Muh <Algholamym@gmail.com>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: noname <zhuk2@duck.com>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: Bezruchenko Simon <worcposj44@gmail.com>
Co-authored-by: João Moreira <jyj@gmx.ie>
Co-authored-by: J R <jr@simplex.chat>
Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org>
2025-03-07 08:21:17 +00:00
Evgeny
2d203c1a18
Translated using Weblate (Russian) (#5720)
Currently translated at 100.0% (258 of 258 strings)

Translation: SimpleX Chat/SimpleX Chat website
Translate-URL: https://hosted.weblate.org/projects/simplex-chat/website/ru/

Co-authored-by: noname <zhuk2@duck.com>
2025-03-07 08:04:16 +00:00
Evgeny
a6631ce629
core: delete members with messages (#5711)
* core: delete members with messages (WIP)

* remove messages

* fix, test

* update query plans
2025-03-07 07:47:32 +00:00
spaced4ndy
5bef7349d8
ios: fix crash on migration to device (#5716)
* ios: fix crash on migration to device

* fix

* remove logs

* changes

* logs

* changes

---------

Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
2025-03-07 07:06:39 +00:00
Evgeny Poberezkin
37050a99c9
android, desktop: add Catalan UI languages 2025-03-06 23:02:47 +00:00
Evgeny
ca31c9a5e9
blog: v6.3 announcement (#5714) 2025-03-06 22:59:32 +00:00
Stanislav Dmitrenko
a0560a5ad0
ios: fix search (#5715) 2025-03-06 11:28:26 +00:00
Stanislav Dmitrenko
a3a27b250c
ios: small fixes (#5712)
* ios: small fixes

* main thread

* fix crash

* fix member opening

* dismissing sheets in order

* theoretical fix of some crashes

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2025-03-06 10:31:05 +00:00
Stanislav Dmitrenko
7a3663f1e0
android, desktop: hovered item in chat (#5684)
* android, desktop: hovered item in chat

* circle background for button

* icon size

* change

* change

* comment

* refactor
2025-03-06 09:42:14 +00:00
Stanislav Dmitrenko
9dac472191
android, desktop: bulk actions with group members (#5708)
* android, desktop: bulk actions with group members

* fix layout

* fix update

* fix responsivenes when closing selecting bar

* events

* unused

* role
2025-03-05 15:01:44 +00:00
Evgeny
8c7df76c24
directory: command to disable all spam filters (#5709)
* directory: command to disable all spam filters

* correct syntax

* move deviceName to core opts
2025-03-05 11:20:30 +00:00
spaced4ndy
3425bd0826
ui: fix "View conditions" view on onboarding offering to accept conditions when no operators are selected (#5710)
* ios: fix "View conditions" view on onboarding

* kotlin
2025-03-05 07:42:59 +00:00
Stanislav Dmitrenko
257208a99b
ios: fix toolbar in chatList on iOS 15 (#5707) 2025-03-04 11:20:14 +00:00
528 changed files with 26706 additions and 15297 deletions

View file

@ -0,0 +1,47 @@
name: "Prebuilt steps for build"
description: "Reusable steps for multiple jobs"
inputs:
java_ver:
required: true
description: "Java version to install"
ghc_ver:
required: true
description: "GHC version to install"
github_ref:
required: true
description: "Git reference"
os:
required: true
description: "Target OS"
cache_path:
required: false
default: "~/.cabal/store"
description: "Cache path"
cabal_ver:
required: false
default: 3.10.1.0
description: "GHC version to install"
runs:
using: "composite"
steps:
- name: Setup Haskell
uses: simplex-chat/setup-haskell-action@v2
with:
ghc-version: ${{ inputs.ghc_ver }}
cabal-version: ${{ inputs.cabal_ver }}
- name: Setup Java
if: startsWith(inputs.github_ref, 'refs/tags/v')
uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: ${{ inputs.java_ver }}
cache: 'gradle'
- name: Restore cached build
uses: actions/cache@v4
with:
path: |
${{ inputs.cache_path }}
dist-newstyle
key: ${{ inputs.os }}-ghc${{ inputs.ghc_ver }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}

View file

@ -0,0 +1,39 @@
name: "Upload binary and update hash"
description: "Reusable steps for multiple jobs"
inputs:
bin_path:
required: true
description: "Path to binary to upload"
bin_name:
required: true
description: "Name of uploaded binary"
bin_hash:
required: true
description: "Message with SHA to include in release"
github_ref:
required: true
description: "Github reference"
github_token:
required: true
description: "Github token"
runs:
using: "composite"
steps:
- name: Upload file with specific name
if: startsWith(inputs.github_ref, 'refs/tags/v')
uses: simplex-chat/upload-release-action@v2
with:
repo_token: ${{ inputs.github_token }}
file: ${{ inputs.bin_path }}
asset_name: ${{ inputs.bin_name }}
tag: ${{ inputs.github_ref }}
- name: Add hash to release notes
if: startsWith(inputs.github_ref, 'refs/tags/v')
uses: simplex-chat/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ inputs.github_token }}
with:
append_body: true
body: |
${{ inputs.bin_hash }}

44
.github/actions/swap/action.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: 'Set Swap Space'
description: 'Add moar swap'
branding:
icon: 'crop'
color: 'orange'
inputs:
swap-size-gb:
description: 'Swap space to create, in Gigabytes.'
required: false
default: '10'
runs:
using: "composite"
steps:
- name: Swap space report before modification
shell: bash
run: |
echo "Memory and swap:"
free -h
echo
swapon --show
echo
- name: Set Swap
shell: bash
run: |
export SWAP_FILE=$(swapon --show=NAME | tail -n 1)
echo "Swap file: $SWAP_FILE"
if [ -z "$SWAP_FILE" ]; then
SWAP_FILE=/opt/swapfile
else
sudo swapoff $SWAP_FILE
sudo rm $SWAP_FILE
fi
sudo fallocate -l ${{ inputs.swap-size-gb }}G $SWAP_FILE
sudo chmod 600 $SWAP_FILE
sudo mkswap $SWAP_FILE
sudo swapon $SWAP_FILE
- name: Swap space report after modification
shell: bash
run: |
echo "Memory and swap:"
free -h
echo
swapon --show
echo

View file

@ -22,17 +22,58 @@ on:
- "README.md" - "README.md"
- "PRIVACY.md" - "PRIVACY.md"
# This workflow uses custom actions (prepare-build and prepare-release) defined in:
#
# .github/actions/
# ├── prepare-build
# │ └── action.yml
# └── prepare-release
# └── action.yml
# Important!
# Do not use always(), it makes build unskippable.
# See: https://github.com/actions/runner/issues/1846#issuecomment-1246102753
jobs: jobs:
prepare-release:
if: startsWith(github.ref, 'refs/tags/v') # =============================
# Global variables
# =============================
# That is the only and less hacky way to setup global variables
# to use in strategy matrix (env:/YAML anchors doesn't work).
# See: https://github.com/orgs/community/discussions/56787#discussioncomment-6041789
# https://github.com/actions/runner/issues/1182
# https://stackoverflow.com/a/77549656
variables:
runs-on: ubuntu-latest
outputs:
GHC_VER: 9.6.3
JAVA_VER: 17
steps:
- name: Dummy job when we have just simple variables
if: false
run: echo
# =============================
# Create release
# =============================
# Create release, but only if it's triggered by tag push.
# On pull requests/commits push, this job will always complete.
maybe-release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Clone project - name: Clone project
if: startsWith(github.ref, 'refs/tags/v')
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Build changelog - name: Build changelog
id: build_changelog id: build_changelog
uses: mikepenz/release-changelog-builder-action@v4 if: startsWith(github.ref, 'refs/tags/v')
uses: simplex-chat/release-changelog-builder-action@v5
with: with:
configuration: .github/changelog_conf.json configuration: .github/changelog_conf.json
failOnError: true failOnError: true
@ -42,7 +83,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create release - name: Create release
uses: softprops/action-gh-release@v1 if: startsWith(github.ref, 'refs/tags/v')
uses: simplex-chat/action-gh-release@v2
with: with:
body: ${{ steps.build_changelog.outputs.changelog }} body: ${{ steps.build_changelog.outputs.changelog }}
prerelease: true prerelease: true
@ -52,183 +94,295 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build: # =========================
name: build-${{ matrix.os }}-${{ matrix.ghc }} # Linux Build
if: always() # =========================
needs: prepare-release
build-linux:
name: "ubuntu-${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ubuntu-${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: 22.04
ghc: "8.10.7"
should_run: ${{ !(github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }}
- os: 22.04
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-ubuntu-22_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
should_run: true
- os: 24.04
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-ubuntu-24_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-24_04-x86_64.deb
should_run: true
steps:
- name: Checkout Code
if: matrix.should_run == true
uses: actions/checkout@v3
- name: Setup swap
if: matrix.ghc == '8.10.7' && matrix.should_run == true
uses: ./.github/actions/swap
with:
swap-size-gb: 30
# Otherwise we run out of disk space with Docker build
- name: Free disk space
if: matrix.should_run == true
shell: bash
run: ./scripts/ci/linux_util_free_space.sh
- name: Restore cached build
if: matrix.should_run == true
uses: actions/cache@v4
with:
path: |
~/.cabal/store
dist-newstyle
key: ubuntu-${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
- name: Set up Docker Buildx
if: matrix.should_run == true
uses: simplex-chat/docker-setup-buildx-action@v3
- name: Build and cache Docker image
if: matrix.should_run == true
uses: simplex-chat/docker-build-push-action@v6
with:
context: .
load: true
file: Dockerfile.build
tags: build/${{ matrix.os }}:latest
build-args: |
TAG=${{ matrix.os }}
GHC=${{ matrix.ghc }}
# Docker needs these flags for AppImage build:
# --device /dev/fuse
# --cap-add SYS_ADMIN
# --security-opt apparmor:unconfined
- name: Start container
if: matrix.should_run == true
shell: bash
run: |
docker run -t -d \
--device /dev/fuse \
--cap-add SYS_ADMIN \
--security-opt apparmor:unconfined \
--name builder \
-v ~/.cabal:/root/.cabal \
-v /home/runner/work/_temp:/home/runner/work/_temp \
-v ${{ github.workspace }}:/project \
build/${{ matrix.os }}:latest
- name: Prepare cabal.project.local
if: matrix.should_run == true
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
# chmod/git commands are used to workaround permission issues when cache is restored
- name: Build CLI
if: matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: |
chmod -R 777 dist-newstyle ~/.cabal && git config --global --add safe.directory '*'
cabal clean
cabal update
cabal build -j --enable-tests
mkdir -p /out
for i in simplex-chat simplex-chat-test; do
bin=$(find /project/dist-newstyle -name "$i" -type f -executable)
chmod +x "$bin"
mv "$bin" /out/
done
strip /out/simplex-chat
- name: Copy tests from container
if: matrix.should_run == true
shell: bash
run: |
docker cp builder:/out/simplex-chat-test .
- name: Copy CLI from container and prepare it
id: linux_cli_prepare
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
docker cp builder:/out/simplex-chat ./${{ matrix.cli_asset_name }}
path="${{ github.workspace }}/${{ matrix.cli_asset_name }}"
echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload CLI
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
uses: ./.github/actions/prepare-release
with:
bin_path: ${{ steps.linux_cli_prepare.outputs.bin_path }}
bin_name: ${{ matrix.cli_asset_name }}
bin_hash: ${{ steps.linux_cli_prepare.outputs.bin_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build Desktop
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: |
scripts/desktop/build-lib-linux.sh
cd apps/multiplatform
./gradlew packageDeb
- name: Prepare Desktop
id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
shell: bash
run: |
path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/deb/simplex_*_amd64.deb )
echo "package_path=$path" >> $GITHUB_OUTPUT
echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload Desktop
uses: ./.github/actions/prepare-release
if: startsWith(github.ref, 'refs/tags/v') && matrix.should_run == true
with:
bin_path: ${{ steps.linux_desktop_build.outputs.package_path }}
bin_name: ${{ matrix.desktop_asset_name }}
bin_hash: ${{ steps.linux_desktop_build.outputs.package_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Build AppImage
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
shell: docker exec -t builder sh -eu {0}
run: |
scripts/desktop/make-appimage-linux.sh
- name: Prepare AppImage
id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
shell: bash
run: |
path=$(echo ${{ github.workspace }}/apps/multiplatform/release/main/*imple*.AppImage)
echo "appimage_path=$path" >> $GITHUB_OUTPUT
echo "appimage_hash=$(echo SHA2-256\(simplex-desktop-x86_64.AppImage\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Upload AppImage
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == '22.04' && matrix.should_run == true
uses: ./.github/actions/prepare-release
with:
bin_path: ${{ steps.linux_appimage_build.outputs.appimage_path }}
bin_name: "simplex-desktop-x86_64.AppImage"
bin_hash: ${{ steps.linux_appimage_build.outputs.appimage_hash }}
github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Fix permissions for cache
if: matrix.should_run == true
shell: bash
run: |
sudo chmod -R 777 dist-newstyle ~/.cabal
sudo chown -R $(id -u):$(id -g) dist-newstyle ~/.cabal
- name: Run tests
if: matrix.should_run == true
timeout-minutes: 120
shell: bash
run: |
i=1
attempts=1
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
while [ "$i" -le "$attempts" ]; do
if ./simplex-chat-test; then
break
else
echo "Attempt $i failed, retrying..."
i=$((i + 1))
sleep 1
fi
done
if [ "$i" -gt "$attempts" ]; then
echo "All "$attempts" attempts failed."
exit 1
fi
# =========================
# MacOS Build
# =========================
build-macos:
name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
- os: ubuntu-20.04
ghc: "8.10.7"
cache_path: ~/.cabal/store
- os: ubuntu-20.04
ghc: "9.6.3"
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-20_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-20_04-x86_64.deb
- os: ubuntu-22.04
ghc: "9.6.3"
cache_path: ~/.cabal/store
asset_name: simplex-chat-ubuntu-22_04-x86-64
desktop_asset_name: simplex-desktop-ubuntu-22_04-x86_64.deb
- os: macos-latest - os: macos-latest
ghc: "9.6.3" ghc: ${{ needs.variables.outputs.GHC_VER }}
cache_path: ~/.cabal/store cli_asset_name: simplex-chat-macos-aarch64
asset_name: simplex-chat-macos-aarch64
desktop_asset_name: simplex-desktop-macos-aarch64.dmg desktop_asset_name: simplex-desktop-macos-aarch64.dmg
openssl_dir: "/opt/homebrew/opt"
- os: macos-13 - os: macos-13
ghc: "9.6.3" ghc: ${{ needs.variables.outputs.GHC_VER }}
cache_path: ~/.cabal/store cli_asset_name: simplex-chat-macos-x86-64
asset_name: simplex-chat-macos-x86-64
desktop_asset_name: simplex-desktop-macos-x86_64.dmg desktop_asset_name: simplex-desktop-macos-x86_64.dmg
- os: windows-latest openssl_dir: "/usr/local/opt"
ghc: "9.6.3"
cache_path: C:/cabal
asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps: steps:
- name: Skip unreliable ghc 8.10.7 build on stable branch - name: Checkout Code
if: matrix.ghc == '8.10.7' && github.ref == 'refs/heads/stable'
run: exit 0
- name: Configure pagefile (Windows)
if: matrix.os == 'windows-latest'
uses: al-cheb/configure-pagefile-action@v1.3
with:
minimum-size: 16GB
maximum-size: 16GB
disk-root: "C:"
- name: Clone project
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup Haskell - name: Prepare build
uses: haskell-actions/setup@v2 uses: ./.github/actions/prepare-build
with: with:
ghc-version: ${{ matrix.ghc }} java_ver: ${{ needs.variables.outputs.JAVA_VER }}
cabal-version: "3.10.1.0" ghc_ver: ${{ matrix.ghc }}
os: ${{ matrix.os }}
github_ref: ${{ github.ref }}
- name: Restore cached build - name: Install OpenSSL
id: restore_cache
uses: actions/cache/restore@v3
with:
path: |
${{ matrix.cache_path }}
dist-newstyle
key: ${{ matrix.os }}-ghc${{ matrix.ghc }}-${{ hashFiles('cabal.project', 'simplex-chat.cabal') }}
# / Unix
- name: Unix prepare cabal.project.local for Mac
if: matrix.os == 'macos-latest'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /opt/homebrew/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /opt/homebrew/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Unix prepare cabal.project.local for Mac
if: matrix.os == 'macos-13'
shell: bash
run: |
echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: /usr/local/opt/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: /usr/local/opt/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local
- name: Install AppImage dependencies
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
run: sudo apt install -y desktop-file-utils
- name: Install openssl for Mac
if: matrix.os == 'macos-latest' || matrix.os == 'macos-13'
run: brew install openssl@3.0 run: brew install openssl@3.0
- name: Unix prepare cabal.project.local for Ubuntu - name: Prepare cabal.project.local
if: matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04'
shell: bash shell: bash
run: | run: |
echo "ignore-project: False" >> cabal.project.local echo "ignore-project: False" >> cabal.project.local
echo "package simplexmq" >> cabal.project.local
echo " extra-include-dirs: ${{ matrix.opnessl_dir }}/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: ${{ matrix.openssl_dir}}/openssl@3.0/lib" >> cabal.project.local
echo "" >> cabal.project.local
echo "package direct-sqlcipher" >> cabal.project.local echo "package direct-sqlcipher" >> cabal.project.local
echo " extra-include-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/include" >> cabal.project.local
echo " extra-lib-dirs: ${{ matrix.openssl_dir }}/openssl@3.0/lib" >> cabal.project.local
echo " flags: +openssl" >> cabal.project.local echo " flags: +openssl" >> cabal.project.local
- name: Unix build CLI - name: Build CLI
id: unix_cli_build id: mac_cli_build
if: matrix.os != 'windows-latest'
shell: bash shell: bash
run: | run: |
cabal build --enable-tests cabal build -j --enable-tests
path=$(cabal list-bin simplex-chat) path=$(cabal list-bin simplex-chat)
echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Unix upload CLI binary to release - name: Upload CLI
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest' if: startsWith(github.ref, 'refs/tags/v')
uses: svenstaro/upload-release-action@v2 uses: ./.github/actions/prepare-release
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} bin_path: ${{ steps.mac_cli_build.outputs.bin_path }}
file: ${{ steps.unix_cli_build.outputs.bin_path }} bin_name: ${{ matrix.cli_asset_name }}
asset_name: ${{ matrix.asset_name }} bin_hash: ${{ steps.mac_cli_build.outputs.bin_hash }}
tag: ${{ github.ref }} github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Unix update CLI binary hash - name: Build Desktop
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os != 'windows-latest'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.unix_cli_build.outputs.bin_hash }}
- name: Setup Java
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name
uses: actions/setup-java@v3
with:
distribution: 'corretto'
java-version: '17'
cache: 'gradle'
- name: Linux build desktop
id: linux_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04')
shell: bash
run: |
scripts/desktop/build-lib-linux.sh
cd apps/multiplatform
./gradlew packageDeb
path=$(echo $PWD/release/main/deb/simplex_*_amd64.deb)
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: Linux make AppImage
id: linux_appimage_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
shell: bash
run: |
scripts/desktop/make-appimage-linux.sh
path=$(echo $PWD/apps/multiplatform/release/main/*imple*.AppImage)
echo "appimage_path=$path" >> $GITHUB_OUTPUT
echo "appimage_hash=$(echo SHA2-512\(simplex-desktop-x86_64.AppImage\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Mac build desktop
id: mac_desktop_build id: mac_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13') if: startsWith(github.ref, 'refs/tags/v')
shell: bash shell: bash
env: env:
APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }} APPLE_SIMPLEX_SIGNING_KEYCHAIN: ${{ secrets.APPLE_SIMPLEX_SIGNING_KEYCHAIN }}
@ -238,88 +392,77 @@ jobs:
scripts/ci/build-desktop-mac.sh scripts/ci/build-desktop-mac.sh
path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg) path=$(echo $PWD/apps/multiplatform/release/main/dmg/SimpleX-*.dmg)
echo "package_path=$path" >> $GITHUB_OUTPUT 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 echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Linux upload desktop package to release - name: Upload Desktop
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') if: startsWith(github.ref, 'refs/tags/v')
uses: svenstaro/upload-release-action@v2 uses: ./.github/actions/prepare-release
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} bin_path: ${{ steps.mac_desktop_build.outputs.package_path }}
file: ${{ steps.linux_desktop_build.outputs.package_path }} bin_name: ${{ matrix.desktop_asset_name }}
asset_name: ${{ matrix.desktop_asset_name }} bin_hash: ${{ steps.mac_desktop_build.outputs.package_hash }}
tag: ${{ github.ref }} github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Linux update desktop package hash - name: Run tests
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && (matrix.os == 'ubuntu-20.04' || matrix.os == 'ubuntu-22.04') timeout-minutes: 120
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.linux_desktop_build.outputs.package_hash }}
- name: Linux upload AppImage to release
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.linux_appimage_build.outputs.appimage_path }}
asset_name: simplex-desktop-x86_64.AppImage
tag: ${{ github.ref }}
- name: Linux update AppImage hash
if: startsWith(github.ref, 'refs/tags/v') && matrix.asset_name && matrix.os == 'ubuntu-20.04'
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.linux_appimage_build.outputs.appimage_hash }}
- name: Mac upload desktop package to release
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
uses: svenstaro/upload-release-action@v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ${{ steps.mac_desktop_build.outputs.package_path }}
asset_name: ${{ matrix.desktop_asset_name }}
tag: ${{ github.ref }}
- name: Mac update desktop package hash
if: startsWith(github.ref, 'refs/tags/v') && (matrix.os == 'macos-latest' || matrix.os == 'macos-13')
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
append_body: true
body: |
${{ steps.mac_desktop_build.outputs.package_hash }}
- name: Cache unix build
uses: actions/cache/save@v3
if: matrix.os != 'windows-latest'
with:
path: |
${{ matrix.cache_path }}
dist-newstyle
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
- name: Unix test
if: matrix.os != 'windows-latest'
timeout-minutes: 40
shell: bash shell: bash
run: cabal test --test-show-details=direct run: |
i=1
attempts=1
${{ (github.ref == 'refs/heads/stable' || startsWith(github.ref, 'refs/tags/v')) }} && attempts=3
while [ "$i" -le "$attempts" ]; do
if cabal test --test-show-details=direct; then
break
else
echo "Attempt $i failed, retrying..."
i=$((i + 1))
sleep 1
fi
done
if [ "$i" -gt "$attempts" ]; then
echo "All "$attempts" attempts failed."
exit 1
fi
# Unix / # =========================
# Windows Build
# =========================
# / Windows build-windows:
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing name: "${{ matrix.os }} (CLI,Desktop), GHC: ${{ matrix.ghc }}"
needs: [maybe-release, variables]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: windows-latest
ghc: ${{ needs.variables.outputs.GHC_VER }}
cli_asset_name: simplex-chat-windows-x86-64
desktop_asset_name: simplex-desktop-windows-x86_64.msi
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Prepare build
uses: ./.github/actions/prepare-build
with:
java_ver: ${{ needs.variables.outputs.JAVA_VER }}
ghc_ver: ${{ matrix.ghc }}
os: ${{ matrix.os }}
cache_path: "C:/cabal"
github_ref: ${{ github.ref }}
- name: Configure pagefile (Windows)
uses: simplex-chat/configure-pagefile-action@v1.4
with:
minimum-size: 16GB
maximum-size: 16GB
disk-root: "C:"
- name: 'Setup MSYS2' - name: 'Setup MSYS2'
if: matrix.os == 'windows-latest' uses: simplex-chat/setup-msys2@v2
uses: msys2/setup-msys2@v2
with: with:
msystem: ucrt64 msystem: ucrt64
update: true update: true
@ -331,10 +474,9 @@ jobs:
toolchain:p toolchain:p
cmake:p cmake:p
# rm -rf dist-newstyle/src/direct-sq* is here because of the bug in cabal's dependency which prevents second build from finishing
- name: Windows build - name: Build CLI
id: windows_build id: windows_cli_build
if: matrix.os == 'windows-latest'
shell: msys2 {0} shell: msys2 {0}
run: | run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
@ -349,70 +491,42 @@ jobs:
rm -rf dist-newstyle/src/direct-sq* rm -rf dist-newstyle/src/direct-sq*
sed -i "s/, unix /--, unix /" simplex-chat.cabal sed -i "s/, unix /--, unix /" simplex-chat.cabal
cabal build --enable-tests cabal build -j --enable-tests
rm -rf dist-newstyle/src/direct-sq* rm -rf dist-newstyle/src/direct-sq*
path=$(cabal list-bin simplex-chat | tail -n 1) path=$(cabal list-bin simplex-chat | tail -n 1)
echo "bin_path=$path" >> $GITHUB_OUTPUT echo "bin_path=$path" >> $GITHUB_OUTPUT
echo "bin_hash=$(echo SHA2-512\(${{ matrix.asset_name }}\)= $(openssl sha512 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT echo "bin_hash=$(echo SHA2-256\(${{ matrix.cli_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Windows upload CLI binary to release - name: Upload CLI
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v')
uses: svenstaro/upload-release-action@v2 uses: ./.github/actions/prepare-release
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} bin_path: ${{ steps.windows_cli_build.outputs.bin_path }}
file: ${{ steps.windows_build.outputs.bin_path }} bin_name: ${{ matrix.cli_asset_name }}
asset_name: ${{ matrix.asset_name }} bin_hash: ${{ steps.windows_cli_build.outputs.bin_hash }}
tag: ${{ github.ref }} github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Windows update CLI binary hash - name: Build Desktop
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_build.outputs.bin_hash }}
- name: Windows build desktop
id: windows_desktop_build id: windows_desktop_build
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v')
shell: msys2 {0} shell: msys2 {0}
run: | run: |
export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo) export PATH=$PATH:/c/ghcup/bin:$(echo /c/tools/ghc-*/bin || echo)
scripts/desktop/build-lib-windows.sh scripts/desktop/build-lib-windows.sh
cd apps/multiplatform cd apps/multiplatform
./gradlew packageMsi ./gradlew packageMsi
rm -rf dist-newstyle/src/direct-sq*
path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g') path=$(echo $PWD/release/main/msi/*imple*.msi | sed 's#/\([a-z]\)#\1:#' | sed 's#/#\\#g')
echo "package_path=$path" >> $GITHUB_OUTPUT 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 echo "package_hash=$(echo SHA2-256\(${{ matrix.desktop_asset_name }}\)= $(openssl sha256 $path | cut -d' ' -f 2))" >> $GITHUB_OUTPUT
- name: Windows upload desktop package to release - name: Upload Desktop
if: startsWith(github.ref, 'refs/tags/v') && matrix.os == 'windows-latest' if: startsWith(github.ref, 'refs/tags/v')
uses: svenstaro/upload-release-action@v2 uses: ./.github/actions/prepare-release
with: with:
repo_token: ${{ secrets.GITHUB_TOKEN }} bin_path: ${{ steps.windows_desktop_build.outputs.package_path }}
file: ${{ steps.windows_desktop_build.outputs.package_path }} bin_name: ${{ matrix.desktop_asset_name }}
asset_name: ${{ matrix.desktop_asset_name }} bin_hash: ${{ steps.windows_desktop_build.outputs.package_hash }}
tag: ${{ github.ref }} github_ref: ${{ github.ref }}
github_token: ${{ secrets.GITHUB_TOKEN }}
- 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 }}
- name: Cache windows build
uses: actions/cache/save@v3
if: matrix.os == 'windows-latest'
with:
path: |
${{ matrix.cache_path }}
dist-newstyle
key: ${{ steps.restore_cache.outputs.cache-primary-key }}
# Windows /

View file

@ -0,0 +1,45 @@
name: Reproduce latest release
on:
workflow_dispatch:
schedule:
- cron: '0 2 * * *' # every day at 02:00 night
jobs:
reproduce:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get latest release
shell: bash
run: |
curl --proto '=https' \
--tlsv1.2 \
-sSf -L \
'https://api.github.com/repos/simplex-chat/simplex-chat/releases/latest' \
2>/dev/null | \
grep -i "tag_name" | \
awk -F \" '{print "TAG="$4}' >> $GITHUB_ENV
- name: Execute reproduce script
run: |
${GITHUB_WORKSPACE}/scripts/reproduce-builds.sh "$TAG"
- name: Check if build has been reproduced
env:
url: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_URL }}
user: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_USER }}
pass: ${{ secrets.STATUS_SIMPLEX_WEBHOOK_PASS }}
run: |
if [ -f "${GITHUB_WORKSPACE}/$TAG/_sha256sums" ]; then
exit 0
else
curl --proto '=https' --tlsv1.2 -sSf \
-u "${user}:${pass}" \
-H 'Content-Type: application/json' \
-d '{"title": "👾 GitHub: Runner", "description": "⛔️ '"$TAG"' did not reproduce."}' \
"$url"
exit 1
fi

View file

@ -33,7 +33,7 @@ jobs:
./website/web.sh ./website/web.sh
- name: Deploy - name: Deploy
uses: peaceiris/actions-gh-pages@v3 uses: simplex-chat/actions-gh-pages@v3
with: with:
publish_dir: ./website/_site publish_dir: ./website/_site
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}

View file

@ -29,7 +29,7 @@ RUN cp ./scripts/cabal.project.local.linux ./cabal.project.local
# Compile simplex-chat # Compile simplex-chat
RUN cabal update RUN cabal update
RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' RUN cabal build exe:simplex-chat --constraint 'simplexmq +client_library' --constraint 'simplex-chat +client_library'
# Strip the binary from debug symbols to reduce size # Strip the binary from debug symbols to reduce size
RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \ RUN bin=$(find /project/dist-newstyle -name "simplex-chat" -type f -executable) && \

92
Dockerfile.build Normal file
View file

@ -0,0 +1,92 @@
# syntax=docker/dockerfile:1.7.0-labs
ARG TAG=24.04
FROM ubuntu:${TAG} AS build
### Build stage
ARG GHC=9.6.3
ARG CABAL=3.10.1.0
ARG JAVA=17
ENV TZ=Etc/UTC \
DEBIAN_FRONTEND=noninteractive
# Install curl, git and and simplex-chat dependencies
RUN apt-get update && \
apt-get install -y curl \
libpq-dev \
git \
sqlite3 \
libsqlite3-dev \
build-essential \
libgmp3-dev \
zlib1g-dev \
llvm \
cmake \
llvm-dev \
libnuma-dev \
libssl-dev \
desktop-file-utils \
patchelf \
ca-certificates \
zip \
wget \
fuse3 \
file \
appstream \
gpg \
unzip &&\
ln -s /bin/fusermount /bin/fusermount3 || :
# Install Java Coretto
# Required, because official Java in Ubuntu
# depends on libjpeg.so.8 and liblcms2.so.2 which are NOT copied into final
# /usr/lib/runtime/lib directory and I do not have time to figure out gradle.kotlin
# to fix this :(
RUN curl --proto '=https' --tlsv1.2 -sSf 'https://apt.corretto.aws/corretto.key' | gpg --dearmor -o /usr/share/keyrings/corretto-keyring.gpg &&\
echo "deb [signed-by=/usr/share/keyrings/corretto-keyring.gpg] https://apt.corretto.aws stable main" > /etc/apt/sources.list.d/corretto.list &&\
apt update &&\
apt install -y java-${JAVA}-amazon-corretto-jdk
# Specify bootstrap Haskell versions
ENV BOOTSTRAP_HASKELL_GHC_VERSION=${GHC}
ENV BOOTSTRAP_HASKELL_CABAL_VERSION=${CABAL}
# Do not install Stack
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK=true
ENV BOOTSTRAP_HASKELL_INSTALL_NO_STACK_HOOK=true
# Install ghcup
RUN curl --proto '=https' --tlsv1.2 -sSf https://get-ghcup.haskell.org | BOOTSTRAP_HASKELL_NONINTERACTIVE=1 sh
# Adjust PATH
ENV PATH="/root/.cabal/bin:/root/.ghcup/bin:$PATH"
# Set both as default
RUN ghcup set ghc "${GHC}" && \
ghcup set cabal "${CABAL}"
#=====================
# Install Android SDK
#=====================
ARG SDK_VERSION=13114758
ENV SDK_VERSION=$SDK_VERSION \
ANDROID_HOME=/root
RUN curl -L -o tools.zip "https://dl.google.com/android/repository/commandlinetools-linux-${SDK_VERSION}_latest.zip" && \
unzip tools.zip && rm tools.zip && \
mv cmdline-tools tools && mkdir "$ANDROID_HOME/cmdline-tools" && mv tools "$ANDROID_HOME/cmdline-tools/" && \
ln -s "$ANDROID_HOME/cmdline-tools/tools" "$ANDROID_HOME/cmdline-tools/latest"
ENV PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/cmdline-tools/tools/bin"
# https://askubuntu.com/questions/885658/android-sdk-repositories-cfg-could-not-be-loaded
RUN mkdir -p ~/.android ~/.gradle && \
touch ~/.android/repositories.cfg && \
echo 'org.gradle.console=plain' > ~/.gradle/gradle.properties &&\
yes | sdkmanager --licenses >/dev/null
ENV PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools
WORKDIR /project

View file

@ -10,7 +10,7 @@
# SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design! # SimpleX - the first messaging platform that has no user identifiers of any kind - 100% private by design!
[<img src="./images/trail-of-bits.jpg" height="100">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="80">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="80">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/) [<img src="./images/trail-of-bits.jpg" height="80">](http://simplex.chat/blog/20221108-simplex-chat-v4.2-security-audit-new-website.html) &nbsp;&nbsp;&nbsp; [<img src="./images/privacy-guides.jpg" height="64">](https://www.privacyguides.org/en/real-time-communication/#simplex-chat) &nbsp;&nbsp;&nbsp; [<img src="./images/whonix-logo.jpg" height="64">](https://www.whonix.org/wiki/Chat#Recommendation) &nbsp;&nbsp;&nbsp; [<img src="./images/kuketz-blog.jpg" height="64">](https://www.kuketz-blog.de/simplex-eindruecke-vom-messenger-ohne-identifier/)
## Welcome to SimpleX Chat! ## Welcome to SimpleX Chat!
@ -72,7 +72,7 @@ You must:
Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment. Messages not following these rules will be deleted, the right to send messages may be revoked, and the access to the new members to the group may be temporarily restricted, to prevent re-joining under a different name - our imperfect group moderation does not have a better solution at the moment.
You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2Fos8FftfoV8zjb2T89fUEjJtF7y64p5av%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAQqMgh0fw2lPhjn3PDIEfAKA_E0-gf8Hr8zzhYnDivRs%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22lBPiveK2mjfUH43SN77R0w%3D%3D%22%7D) You can join an English-speaking users group if you want to ask any questions: [#SimpleX users group](https://simplex.chat/contact#/?v=2-7&smp=smp%3A%2F%2Fhpq7_4gGJiilmz5Rf-CswuU5kZGkm_zOIooSw6yALRg%3D%40smp5.simplex.im%2FiBkJE72asZX1NUZaYFIeKRVk6oVjb-iv%23%2F%3Fv%3D1-3%26dh%3DMCowBQYDK2VuAyEAinqu3j74AMjODLoIRR487ZW6ysip_dlpD6Zxk18SPFY%253D%26srv%3Djjbyvoemxysm7qxap7m5d5m35jzv5qq6gnlv7s4rsn7tdwwmuqciwpid.onion&data=%7B%22groupLinkId%22%3A%223wAFGCLygQHR5AwynZOHlQ%3D%3D%22%7D)
There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform: There is also a group [#simplex-devs](https://simplex.chat/contact#/?v=1-4&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FvYCRjIflKNMGYlfTkuHe4B40qSlQ0439%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAHNdcqNbzXZhyMoSBjT2R0-Eb1EPaLyUg3KZjn-kmM1w%253D%26srv%3Dbylepyau3ty4czmn77q4fglvperknl4bi2eb2fdy2bh4jxtf32kf73yd.onion&data=%7B%22type%22%3A%22group%22%2C%22groupLinkId%22%3A%22PD20tcXjw7IpkkMCfR6HLA%3D%3D%22%7D) for developers who build on SimpleX platform:
@ -110,6 +110,15 @@ After you connect, you can [verify connection security code](./blog/20230103-sim
Read about the app features and settings in the new [User guide](./docs/guide/README.md). Read about the app features and settings in the new [User guide](./docs/guide/README.md).
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Help translating SimpleX Chat ## Help translating SimpleX Chat
Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages. Thanks to our users and [Weblate](https://hosted.weblate.org/engage/simplex-chat/), SimpleX Chat apps, website and documents are translated to many other languages.
@ -141,15 +150,6 @@ Join our translators to help SimpleX grow!
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!
## Contribute
We would love to have you join the development! You can help us with:
- [share the color theme](./docs/THEMES.md) you use in Android app!
- writing a tutorial or recipes about hosting servers, chat bot automations, etc.
- contributing to SimpleX Chat knowledge-base.
- developing features - please connect to us via chat so we can help you get started.
## Please support us with your donations ## Please support us with your donations
Huge thank you to everybody who donated to SimpleX Chat! Huge thank you to everybody who donated to SimpleX Chat!
@ -169,6 +169,7 @@ It is possible to donate via:
- ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - ETH: 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692 - USDT (Ethereum): 0xD9ee7Db0AD0dc1Dfa7eD53290199ED06beA04692
- ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg - ZEC: t1fwjQW5gpFhDqXNhxqDWyF9j9WeKvVS5Jg
- ZEC shielded: u16rnvkflumf5uw9frngc2lymvmzgdr2mmc9unyu0l44unwfmdcpfm0axujd2w34ct3ye709azxsqge45705lpvvqu264ltzvfay55ygyq
- DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf - DOGE: D99pV4n9TrPxBPCkQGx4w4SMSa6QjRBxPf
- SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu - SOL: 7JCf5m3TiHmYKZVr6jCu1KeZVtb9Y1jRMQDU69p5ARnu
- please ask if you want to donate any other coins. - please ask if you want to donate any other coins.
@ -234,6 +235,10 @@ You can use SimpleX with your own servers and still communicate with people usin
Recent and important updates: Recent and important updates:
[Mar 8, 2025. SimpleX Chat v6.3: new user experience and safety in public groups](./blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.md)
[Jan 14, 2025. SimpleX network: large groups and privacy-preserving content moderation](./blog/20250114-simplex-network-large-groups-privacy-preserving-content-moderation.md)
[Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md) [Dec 10, 2024. SimpleX network: preset servers operated by Flux, business chats and more with v6.2 of the apps](./20241210-simplex-network-v6-2-servers-by-flux-business-chats.md)
[Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md) [Oct 14, 2024. SimpleX network: security review of protocols design by Trail of Bits, v6.1 released with better calls and user experience.](./blog/20241014-simplex-network-v6-1-security-review-better-calls-user-experience.md)
@ -305,12 +310,13 @@ What is already implemented:
15. Manual messaging queue rotations to move conversation to another SMP relay. 15. Manual messaging queue rotations to move conversation to another SMP relay.
16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html). 16. Sending end-to-end encrypted files using [XFTP protocol](https://simplex.chat/blog/20230301-simplex-file-transfer-protocol.html).
17. Local files encryption. 17. Local files encryption.
18. [Reproducible server builds](./docs/SERVER.md#reproduce-builds).
We plan to add: We plan to add:
1. 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). 1. 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).
2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time. 2. Message "mixing" - adding latency to message delivery, to protect against traffic correlation by message time.
3. 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. 3. Reproducible clients builds this is a complex problem, but we are aiming to have it in 2025 at least partially.
4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party. 4. Recipients' XFTP relays to reduce traffic and conceal IP addresses from the relays chosen, and potentially controlled, by another party.
## For developers ## For developers

View file

@ -54,7 +54,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
try await apiVerifyToken(token: token, nonce: nonce, code: verification) try await apiVerifyToken(token: token, nonce: nonce, code: verification)
m.tokenStatus = .active m.tokenStatus = .active
} catch { } catch {
if let cr = error as? ChatResponse, case .chatCmdError(_, .errorAgent(.NTF(.AUTH))) = cr { if let cr = error as? ChatError, case .errorAgent(.NTF(.AUTH)) = cr {
m.tokenStatus = .expired m.tokenStatus = .expired
} }
logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))") logger.error("AppDelegate: didReceiveRemoteNotification: apiVerifyToken or apiIntervalNofication error: \(responseError(error))")

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "vertical_logo_x1.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "vertical_logo_x2.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "vertical_logo_x3.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -11,12 +11,10 @@ import SimpleXChat
private enum NoticesSheet: Identifiable { private enum NoticesSheet: Identifiable {
case whatsNew(updatedConditions: Bool) case whatsNew(updatedConditions: Bool)
case updatedConditions
var id: String { var id: String {
switch self { switch self {
case .whatsNew: return "whatsNew" case .whatsNew: return "whatsNew"
case .updatedConditions: return "updatedConditions"
} }
} }
} }
@ -76,7 +74,7 @@ struct ContentView: View {
} }
} }
@ViewBuilder func allViews() -> some View { func allViews() -> some View {
ZStack { ZStack {
let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted let showCallArea = chatModel.activeCall != nil && chatModel.activeCall?.callState != .waitCapabilities && chatModel.activeCall?.callState != .invitationAccepted
// contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings. // contentView() has to be in a single branch, so that enabling authentication doesn't trigger re-rendering and close settings.
@ -211,7 +209,7 @@ struct ContentView: View {
} }
} }
@ViewBuilder private func activeCallInteractiveArea(_ call: Call) -> some View { private func activeCallInteractiveArea(_ call: Call) -> some View {
HStack { HStack {
Text(call.contact.displayName).font(.body).foregroundColor(.white) Text(call.contact.displayName).font(.body).foregroundColor(.white)
Spacer() Spacer()
@ -278,10 +276,8 @@ struct ContentView: View {
let showWhatsNew = shouldShowWhatsNew() let showWhatsNew = shouldShowWhatsNew()
let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false let showUpdatedConditions = chatModel.conditions.conditionsAction?.showNotice ?? false
noticesShown = showWhatsNew || showUpdatedConditions noticesShown = showWhatsNew || showUpdatedConditions
if showWhatsNew { if showWhatsNew || showUpdatedConditions {
noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions) noticesSheetItem = .whatsNew(updatedConditions: showUpdatedConditions)
} else if showUpdatedConditions {
noticesSheetItem = .updatedConditions
} }
} }
} }
@ -300,14 +296,6 @@ struct ContentView: View {
.if(updatedConditions) { v in .if(updatedConditions) { v in
v.task { await setConditionsNotified_() } v.task { await setConditionsNotified_() }
} }
case .updatedConditions:
UsageConditionsView(
currUserServers: Binding.constant([]),
userServers: Binding.constant([]),
updated: true
)
.modifier(ThemedBackground(grouped: true))
.task { await setConditionsNotified_() }
} }
} }
if chatModel.setDeliveryReceipts { if chatModel.setDeliveryReceipts {
@ -455,12 +443,12 @@ struct ContentView: View {
} }
func connectViaUrl() { func connectViaUrl() {
dismissAllSheets() {
let m = ChatModel.shared let m = ChatModel.shared
if let url = m.appOpenUrl { if let url = m.appOpenUrl {
m.appOpenUrl = nil m.appOpenUrl = nil
dismissAllSheets() {
var path = url.path var path = url.path
if (path == "/contact" || path == "/invitation") { if (path == "/contact" || path == "/invitation" || path == "/a" || path == "/c" || path == "/g" || path == "/i") {
path.removeFirst() path.removeFirst()
let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)") let link = url.absoluteString.replacingOccurrences(of: "///\(path)", with: "/\(path)")
planAndConnect( planAndConnect(

File diff suppressed because it is too large Load diff

View file

@ -30,9 +30,18 @@ actor TerminalItems {
} }
} }
func addCommand(_ start: Date, _ cmd: ChatCommand, _ resp: ChatResponse) async { func addCommand<R: ChatAPIResult>(_ start: Date, _ cmd: ChatCommand, _ res: APIResult<R>) async {
await add(.cmd(start, cmd)) await add(.cmd(start, cmd))
await add(.resp(.now, resp)) await addResult(res)
}
func addResult<R: ChatAPIResult>(_ res: APIResult<R>) async {
let item: TerminalItem = switch res {
case let .result(r): .res(.now, r)
case let .error(e): .err(.now, e)
case let .invalid(type, json): .bad(.now, type, json)
}
await add(item)
} }
} }
@ -54,9 +63,7 @@ class ItemsModel: ObservableObject {
willSet { publisher.send() } willSet { publisher.send() }
} }
// set listener here that will be notified on every add/delete of a chat item
let chatState = ActiveChatState() let chatState = ActiveChatState()
var chatItemsChangesListener: RecalculatePositions = RecalculatePositions()
// Publishes directly to `objectWillChange` publisher, // Publishes directly to `objectWillChange` publisher,
// this will cause reversedChatItems to be rendered without throttling // this will cause reversedChatItems to be rendered without throttling
@ -66,6 +73,10 @@ class ItemsModel: ObservableObject {
private var navigationTimeoutTask: Task<Void, Never>? = nil private var navigationTimeoutTask: Task<Void, Never>? = nil
private var loadChatTask: Task<Void, Never>? = nil private var loadChatTask: Task<Void, Never>? = nil
var lastItemsLoaded: Bool {
chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id
}
init() { init() {
publisher publisher
.throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true) .throttle(for: 0.2, scheduler: DispatchQueue.main, latest: true)
@ -569,7 +580,7 @@ final class ChatModel: ObservableObject {
ci.meta.itemStatus = status ci.meta.itemStatus = status
} }
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.chatItemsChangesListener.added((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0)
im.itemAdded = true im.itemAdded = true
ChatItemDummyModel.shared.sendUpdate() ChatItemDummyModel.shared.sendUpdate()
return true return true
@ -617,13 +628,52 @@ final class ChatModel: ObservableObject {
if let i = getChatItemIndex(cItem) { if let i = getChatItemIndex(cItem) {
withAnimation { withAnimation {
let item = im.reversedChatItems.remove(at: i) let item = im.reversedChatItems.remove(at: i)
im.chatItemsChangesListener.removed([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed())
} }
} }
} }
VoiceItemState.stopVoiceInChatView(cInfo, cItem) VoiceItemState.stopVoiceInChatView(cInfo, cItem)
} }
func removeMemberItems(_ removedMember: GroupMember, byMember: GroupMember, _ groupInfo: GroupInfo) {
// this should not happen, only another member can "remove" user, user can only "leave" (another event).
if byMember.groupMemberId == groupInfo.membership.groupMemberId {
logger.debug("exiting removeMemberItems")
return
}
if chatId == groupInfo.id {
for i in 0..<im.reversedChatItems.count {
if let updatedItem = removedUpdatedItem(im.reversedChatItems[i]) {
_updateChatItem(at: i, with: updatedItem)
}
}
} else if let chat = getChat(groupInfo.id),
chat.chatItems.count > 0,
let updatedItem = removedUpdatedItem(chat.chatItems[0]) {
chat.chatItems = [updatedItem]
}
func removedUpdatedItem(_ item: ChatItem) -> ChatItem? {
let newContent: CIContent
if case .groupSnd = item.chatDir, removedMember.groupMemberId == groupInfo.membership.groupMemberId {
newContent = .sndModerated
} else if case let .groupRcv(groupMember) = item.chatDir, groupMember.groupMemberId == removedMember.groupMemberId {
newContent = .rcvModerated
} else {
return nil
}
var updatedItem = item
updatedItem.meta.itemDeleted = .moderated(deletedTs: Date.now, byGroupMember: byMember)
if groupInfo.fullGroupPreferences.fullDelete.on {
updatedItem.content = newContent
}
if item.isActiveReport {
decreaseGroupReportsCounter(groupInfo.id)
}
return updatedItem
}
}
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous { if previous {
@ -666,7 +716,7 @@ final class ChatModel: ObservableObject {
let cItem = ChatItem.liveDummy(chatInfo.chatType) let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation { withAnimation {
im.reversedChatItems.insert(cItem, at: 0) im.reversedChatItems.insert(cItem, at: 0)
im.chatItemsChangesListener.added((cItem.id, cItem.isRcvNew), 0) im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0)
im.itemAdded = true im.itemAdded = true
} }
return cItem return cItem
@ -700,7 +750,7 @@ final class ChatModel: ObservableObject {
markChatItemRead_(i) markChatItemRead_(i)
i += 1 i += 1
} }
im.chatItemsChangesListener.read(nil, im.reversedChatItems.reversed()) im.chatState.itemsRead(nil, im.reversedChatItems.reversed())
} }
} }
func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) {
@ -724,7 +774,7 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id { if chatId == cInfo.id {
chatItemStatuses = [:] chatItemStatuses = [:]
im.reversedChatItems = [] im.reversedChatItems = []
im.chatItemsChangesListener.cleared() im.chatState.clear()
} }
} }
@ -742,7 +792,7 @@ final class ChatModel: ObservableObject {
} }
i += 1 i += 1
} }
im.chatItemsChangesListener.read(unreadItemIds, im.reversedChatItems.reversed()) im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed())
} }
self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead)
} }
@ -1102,27 +1152,6 @@ final class Chat: ObservableObject, Identifiable, ChatLike {
) )
} }
var userCanSend: Bool {
switch chatInfo {
case .direct: return true
case let .group(groupInfo):
let m = groupInfo.membership
return m.memberActive && m.memberRole >= .member
case .local:
return true
default: return false
}
}
var userIsObserver: Bool {
switch chatInfo {
case let .group(groupInfo):
let m = groupInfo.membership
return m.memberActive && m.memberRole == .observer
default: return false
}
}
var unreadTag: Bool { var unreadTag: Bool {
switch chatInfo.chatSettings?.enableNtfs { switch chatInfo.chatSettings?.enableNtfs {
case .all: chatStats.unreadChat || chatStats.unreadCount > 0 case .all: chatStats.unreadChat || chatStats.unreadCount > 0

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ struct SimpleXApp: App {
@Environment(\.scenePhase) var scenePhase @Environment(\.scenePhase) var scenePhase
@State private var enteredBackgroundAuthenticated: TimeInterval? = nil @State private var enteredBackgroundAuthenticated: TimeInterval? = nil
@State private var appOpenUrlLater: URL?
init() { init() {
DispatchQueue.global(qos: .background).sync { DispatchQueue.global(qos: .background).sync {
@ -42,7 +43,11 @@ struct SimpleXApp: App {
.environmentObject(AppTheme.shared) .environmentObject(AppTheme.shared)
.onOpenURL { url in .onOpenURL { url in
logger.debug("ContentView.onOpenURL: \(url)") logger.debug("ContentView.onOpenURL: \(url)")
if AppChatState.shared.value == .active {
chatModel.appOpenUrl = url chatModel.appOpenUrl = url
} else {
appOpenUrlLater = url
}
} }
.onAppear() { .onAppear() {
// Present screen for continue migration if it wasn't finished yet // Present screen for continue migration if it wasn't finished yet
@ -93,9 +98,18 @@ struct SimpleXApp: App {
if !chatModel.showCallView && !CallController.shared.hasActiveCalls() { if !chatModel.showCallView && !CallController.shared.hasActiveCalls() {
await updateCallInvitations() await updateCallInvitations()
} }
if let url = appOpenUrlLater {
await MainActor.run {
appOpenUrlLater = nil
chatModel.appOpenUrl = url
} }
} }
} }
} else if let url = appOpenUrlLater {
appOpenUrlLater = nil
chatModel.appOpenUrl = url
}
}
} }
} }
default: default:

View file

@ -243,7 +243,7 @@ struct ActiveCallView: View {
ChatReceiver.shared.messagesChannel = nil ChatReceiver.shared.messagesChannel = nil
return return
} }
if case let .chatItemsStatusesUpdated(_, chatItems) = msg, if case let .result(.chatItemsStatusesUpdated(_, chatItems)) = msg,
chatItems.contains(where: { ci in chatItems.contains(where: { ci in
ci.chatInfo.id == call.contact.id && ci.chatInfo.id == call.contact.id &&
ci.chatItem.content.isSndCall && ci.chatItem.content.isSndCall &&
@ -361,7 +361,7 @@ struct ActiveCallOverlay: View {
HStack { HStack {
Text(call.encryptionStatus) Text(call.encryptionStatus)
if let connInfo = call.connectionInfo { if let connInfo = call.connectionInfo {
Text("(") + Text(connInfo.text) + Text(")") Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
} }
} }
} }
@ -390,7 +390,7 @@ struct ActiveCallOverlay: View {
HStack { HStack {
Text(call.encryptionStatus) Text(call.encryptionStatus)
if let connInfo = call.connectionInfo { if let connInfo = call.connectionInfo {
Text("(") + Text(connInfo.text) + Text(")") Text(verbatim: "(") + Text(connInfo.text) + Text(verbatim: ")")
} }
} }
} }
@ -467,7 +467,7 @@ struct ActiveCallOverlay: View {
.disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true) .disabled(call.initialCallType == .audio && client.activeCall?.peerHasOldVersion == true)
} }
@ViewBuilder private func flipCameraButton() -> some View { private func flipCameraButton() -> some View {
controlButton(call, "arrow.triangle.2.circlepath", padding: 12) { controlButton(call, "arrow.triangle.2.circlepath", padding: 12) {
Task { Task {
if await WebRTCClient.isAuthorized(for: .video) { if await WebRTCClient.isAuthorized(for: .video) {
@ -477,11 +477,11 @@ struct ActiveCallOverlay: View {
} }
} }
@ViewBuilder private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View { private func controlButton(_ call: Call, _ imageName: String, padding: CGFloat, _ perform: @escaping () -> Void) -> some View {
callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform) callButton(imageName, call.peerMediaSources.hasVideo ? Color.black.opacity(0.2) : Color.white.opacity(0.2), padding: padding, perform)
} }
@ViewBuilder private func audioDevicePickerButton() -> some View { private func audioDevicePickerButton() -> some View {
AudioDevicePicker() AudioDevicePicker()
.opacity(0.8) .opacity(0.8)
.scaleEffect(2) .scaleEffect(2)

View file

@ -7,7 +7,7 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat @preconcurrency import SimpleXChat
func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View { func infoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
HStack { HStack {

View file

@ -50,7 +50,7 @@ struct CICallItemView: View {
Image(systemName: "phone.connection").foregroundColor(.green) Image(systemName: "phone.connection").foregroundColor(.green)
} }
@ViewBuilder private func endedCallIcon(_ sent: Bool) -> some View { private func endedCallIcon(_ sent: Bool) -> some View {
HStack { HStack {
Image(systemName: "phone.down") Image(systemName: "phone.down")
Text(durationText(duration)).foregroundColor(theme.colors.secondary) Text(durationText(duration)).foregroundColor(theme.colors.secondary)
@ -60,16 +60,16 @@ struct CICallItemView: View {
@ViewBuilder private func acceptCallButton() -> some View { @ViewBuilder private func acceptCallButton() -> some View {
if case let .direct(contact) = chat.chatInfo { if case let .direct(contact) = chat.chatInfo {
Button { Label("Answer call", systemImage: "phone.arrow.down.left")
.foregroundColor(theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded {
if let invitation = m.callInvitations[contact.id] { if let invitation = m.callInvitations[contact.id] {
CallController.shared.answerCall(invitation: invitation) CallController.shared.answerCall(invitation: invitation)
logger.debug("acceptCallButton call answered") logger.debug("acceptCallButton call answered")
} else { } else {
AlertManager.shared.showAlertMsg(title: "Call already ended!") AlertManager.shared.showAlertMsg(title: "Call already ended!")
} }
} label: { })
Label("Answer call", systemImage: "phone.arrow.down.left")
}
} else { } else {
Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary) Image(systemName: "phone.arrow.down.left").foregroundColor(theme.colors.secondary)
} }

View file

@ -26,9 +26,9 @@ struct CIFeaturePreferenceView: View {
allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) { allowed != .no && ct.allowsFeature(feature) && !ct.userAllowsFeature(feature) {
let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil let setParam = feature == .timedMessages && ct.mergedPreferences.timedMessages.userPreference.preference.ttl == nil
featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept") featurePreferenceView(acceptText: setParam ? "Set 1 day" : "Accept")
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil) allowFeatureToContact(ct, feature, param: setParam ? 86400 : nil)
} })
} else { } else {
featurePreferenceView() featurePreferenceView()
} }

View file

@ -19,12 +19,11 @@ struct CIFileView: View {
var body: some View { var body: some View {
if smallViewSize != nil { if smallViewSize != nil {
fileIndicator() fileIndicator()
.onTapGesture(perform: fileAction) .simultaneousGesture(TapGesture().onEnded(fileAction))
} else { } else {
let metaReserve = edited let metaReserve = edited
? " " ? " "
: " " : " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) { HStack(alignment: .bottom, spacing: 6) {
fileIndicator() fileIndicator()
.padding(.top, 5) .padding(.top, 5)
@ -50,11 +49,12 @@ struct CIFileView: View {
.padding(.bottom, 6) .padding(.bottom, 6)
.padding(.leading, 10) .padding(.leading, 10)
.padding(.trailing, 12) .padding(.trailing, 12)
} .simultaneousGesture(TapGesture().onEnded(fileAction))
.disabled(!itemInteractive) .disabled(!itemInteractive)
} }
} }
@inline(__always)
private var itemInteractive: Bool { private var itemInteractive: Bool {
if let file = file { if let file = file {
switch (file.fileStatus) { switch (file.fileStatus) {

View file

@ -84,12 +84,12 @@ struct CIGroupInvitationView: View {
} }
if action { if action {
v.onTapGesture { v.simultaneousGesture(TapGesture().onEnded {
inProgress = true inProgress = true
joinGroup(groupInvitation.groupId) { joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false } await MainActor.run { inProgress = false }
} }
} })
.disabled(inProgress) .disabled(inProgress)
} else { } else {
v v

View file

@ -31,7 +31,9 @@ struct CIImageView: View {
.if(!smallView) { view in .if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred)) view.modifier(PrivacyBlur(blurred: $blurred))
} }
.onTapGesture { showFullScreenImage = true } .if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded { showFullScreenImage = true })
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false showFullScreenImage = false
} }
@ -43,7 +45,7 @@ struct CIImageView: View {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred)) imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
} }
} }
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
if let file = file { if let file = file {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation, .rcvAborted:
@ -80,7 +82,7 @@ struct CIImageView: View {
default: () default: ()
} }
} }
} })
} }
} }
.onDisappear { .onDisappear {

View file

@ -7,10 +7,11 @@
// //
import SwiftUI import SwiftUI
import SimpleXChat
struct CIInvalidJSONView: View { struct CIInvalidJSONView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var json: String var json: Data?
@State private var showJSON = false @State private var showJSON = false
var body: some View { var body: some View {
@ -23,16 +24,16 @@ struct CIInvalidJSONView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground)) .background(Color(uiColor: .tertiarySystemGroupedBackground))
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture { showJSON = true } .simultaneousGesture(TapGesture().onEnded { showJSON = true })
.appSheet(isPresented: $showJSON) { .appSheet(isPresented: $showJSON) {
invalidJSONView(json) invalidJSONView(dataToString(json))
} }
} }
} }
func invalidJSONView(_ json: String) -> some View { func invalidJSONView(_ json: String) -> some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Button { Button { // this is used in the sheet, Button works here
showShareSheet(items: [json]) showShareSheet(items: [json])
} label: { } label: {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
@ -49,6 +50,6 @@ func invalidJSONView(_ json: String) -> some View {
struct CIInvalidJSONView_Previews: PreviewProvider { struct CIInvalidJSONView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
CIInvalidJSONView(json: "{}") CIInvalidJSONView(json: "{}".data(using: .utf8)!)
} }
} }

View file

@ -21,15 +21,15 @@ struct CILinkView: View {
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred)) .modifier(PrivacyBlur(blurred: $blurred))
.if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded {
openBrowserAlert(uri: linkPreview.uri)
})
}
} }
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title) Text(linkPreview.title)
.lineLimit(3) .lineLimit(3)
// if linkPreview.description != "" {
// Text(linkPreview.description)
// .font(.subheadline)
// .lineLimit(12)
// }
Text(linkPreview.uri.absoluteString) Text(linkPreview.uri.absoluteString)
.font(.caption) .font(.caption)
.lineLimit(1) .lineLimit(1)
@ -37,10 +37,32 @@ struct CILinkView: View {
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.simultaneousGesture(TapGesture().onEnded {
openBrowserAlert(uri: linkPreview.uri)
})
} }
} }
} }
func openBrowserAlert(uri: URL) {
showAlert(
NSLocalizedString("Open link?", comment: "alert title"),
message: uri.absoluteString,
actions: {[
UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"),
style: .default,
handler: { _ in }
),
UIAlertAction(
title: NSLocalizedString("Open", comment: "alert action"),
style: .default,
handler: { _ in UIApplication.shared.open(uri) }
)
]}
)
}
struct LargeLinkPreview_Previews: PreviewProvider { struct LargeLinkPreview_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let preview = LinkPreview( let preview = LinkPreview(

View file

@ -20,12 +20,11 @@ struct CIMemberCreatedContactView: View {
case let .groupRcv(groupMember): case let .groupRcv(groupMember):
if let contactId = groupMember.memberContactId { if let contactId = groupMember.memberContactId {
memberCreatedContactView(openText: "Open") memberCreatedContactView(openText: "Open")
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
ItemsModel.shared.loadOpenChat("@\(contactId)") {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
DispatchQueue.main.async {
ItemsModel.shared.loadOpenChat("@\(contactId)")
}
} }
})
} else { } else {
memberCreatedContactView() memberCreatedContactView()
} }

View file

@ -15,7 +15,7 @@ struct CIMetaView: View {
@Environment(\.showTimestamp) var showTimestamp: Bool @Environment(\.showTimestamp) var showTimestamp: Bool
var chatItem: ChatItem var chatItem: ChatItem
var metaColor: Color var metaColor: Color
var paleMetaColor = Color(UIColor.tertiaryLabel) var paleMetaColor = Color(uiColor: .tertiaryLabel)
var showStatus = true var showStatus = true
var showEdited = true var showEdited = true
var invertedMaterial = false var invertedMaterial = false
@ -152,11 +152,13 @@ func ciMetaText(
return r.font(.caption) return r.font(.caption)
} }
@inline(__always)
private func statusIconText(_ icon: String, _ color: Color?) -> Text { private func statusIconText(_ icon: String, _ color: Color?) -> Text {
colored(Text(Image(systemName: icon)), color) colored(Text(Image(systemName: icon)), color)
} }
// Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier // Applying `foregroundColor(nil)` breaks `.invertedForegroundStyle` modifier
@inline(__always)
private func colored(_ t: Text, _ color: Color?) -> Text { private func colored(_ t: Text, _ color: Color?) -> Text {
if let color { if let color {
t.foregroundColor(color) t.foregroundColor(color)

View file

@ -68,7 +68,7 @@ struct CIRcvDecryptionError: View {
} }
} }
@ViewBuilder private func viewBody() -> some View { private func viewBody() -> some View {
Group { Group {
if case let .direct(contact) = chat.chatInfo, if case let .direct(contact) = chat.chatInfo,
let contactStats = contact.activeConn?.connectionStats { let contactStats = contact.activeConn?.connectionStats {
@ -133,7 +133,7 @@ struct CIRcvDecryptionError: View {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .simultaneousGesture(TapGesture().onEnded(onClick))
.padding(.vertical, 6) .padding(.vertical, 6)
.textSelection(.disabled) .textSelection(.disabled)
} }
@ -151,7 +151,7 @@ struct CIRcvDecryptionError: View {
CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary) CIMetaView(chat: chat, chatItem: chatItem, metaColor: theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
.onTapGesture(perform: { onClick() }) .simultaneousGesture(TapGesture().onEnded(onClick))
.padding(.vertical, 6) .padding(.vertical, 6)
.textSelection(.disabled) .textSelection(.disabled)
} }
@ -161,13 +161,13 @@ struct CIRcvDecryptionError: View {
let why = Text(decryptErrorReason) let why = Text(decryptErrorReason)
switch msgDecryptError { switch msgDecryptError {
case .ratchetHeader: case .ratchetHeader:
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
case .tooManySkipped: case .tooManySkipped:
message = Text("\(msgCount) messages skipped.") + Text("\n") + why message = Text("\(msgCount) messages skipped.") + textNewLine + why
case .ratchetEarlier: case .ratchetEarlier:
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
case .other: case .other:
message = Text("\(msgCount) messages failed to decrypt.") + Text("\n") + why message = Text("\(msgCount) messages failed to decrypt.") + textNewLine + why
case .ratchetSync: case .ratchetSync:
message = Text("Encryption re-negotiation failed.") message = Text("Encryption re-negotiation failed.")
} }

View file

@ -47,17 +47,22 @@ struct CIVideoView: View {
let file = chatItem.file let file = chatItem.file
ZStack(alignment: smallView ? .topLeading : .center) { ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView { if let file, let preview {
smallVideoView(decrypted, file, preview) if let urlDecrypted {
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted { if smallView {
videoView(player, decrypted, file, preview, duration) smallVideoView(urlDecrypted, file, preview)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView { } else if let player {
smallVideoViewEncrypted(file, defaultPreview) videoView(player, urlDecrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil { }
videoViewEncrypted(file, defaultPreview, duration) } else if file.loaded {
} else if let preview, let file { if smallView {
smallVideoViewEncrypted(file, preview)
} else {
videoViewEncrypted(file, preview, duration)
}
} else {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } } Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
switch file.fileStatus { switch file.fileStatus {
case .rcvInvitation, .rcvAborted: case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
@ -80,6 +85,7 @@ struct CIVideoView: View {
case .rcvCancelled: () // TODO case .rcvCancelled: () // TODO
default: () default: ()
} }
})
} }
} }
if !smallView { if !smallView {
@ -87,17 +93,11 @@ struct CIVideoView: View {
} }
} }
if !blurred, let file, showDownloadButton(file.fileStatus) { if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView { if !smallView || !file.showStatusIconInSmallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill") playPauseIcon("play.fill")
} .simultaneousGesture(TapGesture().onEnded {
} else if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile) receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} })
} }
} }
} }
@ -151,26 +151,25 @@ struct CIVideoView: View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview) imageView(defaultPreview)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) { decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil showFullScreenPlayer = urlDecrypted != nil
} }
} })
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !blurred { if !blurred {
if !decryptionInProgress { if !decryptionInProgress {
Button { playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.simultaneousGesture(TapGesture().onEnded {
decrypt(file: file) { decrypt(file: file) {
if urlDecrypted != nil { if urlDecrypted != nil {
videoPlaying = true videoPlaying = true
player?.play() player?.play()
} }
} }
} label: { })
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed) .disabled(!canBePlayed)
} else { } else {
videoDecryptionProgress() videoDecryptionProgress()
@ -194,7 +193,8 @@ struct CIVideoView: View {
} }
} }
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred)) .modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
.onTapGesture { .if(!blurred) { v in
v.simultaneousGesture(TapGesture().onEnded {
switch player.timeControlStatus { switch player.timeControlStatus {
case .playing: case .playing:
player.pause() player.pause()
@ -205,17 +205,17 @@ struct CIVideoView: View {
} }
default: () default: ()
} }
})
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false showFullScreenPlayer = false
} }
if !videoPlaying && !blurred { if !videoPlaying && !blurred {
Button { playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.simultaneousGesture(TapGesture().onEnded {
m.stopPreviousRecPlay = url m.stopPreviousRecPlay = url
player.play() player.play()
} label: { })
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
.disabled(!canBePlayed) .disabled(!canBePlayed)
} }
} }
@ -235,7 +235,7 @@ struct CIVideoView: View {
return ZStack(alignment: .topLeading) { return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local) let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file) smallViewImageView(preview, file)
.onTapGesture { .onTapGesture { // this is shown in chat list, where onTapGesture works
decrypt(file: file) { decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil showFullScreenPlayer = urlDecrypted != nil
} }
@ -256,7 +256,7 @@ struct CIVideoView: View {
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View { private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) { return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file) smallViewImageView(preview, file)
.onTapGesture { .onTapGesture { // this is shown in chat list, where onTapGesture works
showFullScreenPlayer = true showFullScreenPlayer = true
} }
.onChange(of: m.activeCallViewIsCollapsed) { _ in .onChange(of: m.activeCallViewIsCollapsed) { _ in
@ -354,14 +354,14 @@ struct CIVideoView: View {
case .sndCancelled: fileIcon("xmark", 10, 13) case .sndCancelled: fileIcon("xmark", 10, 13)
case let .sndError(sndFileError): case let .sndError(sndFileError):
fileIcon("xmark", 10, 13) fileIcon("xmark", 10, 13)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError) showFileErrorAlert(sndFileError)
} })
case let .sndWarning(sndFileError): case let .sndWarning(sndFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13) fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError, temporary: true) showFileErrorAlert(sndFileError, temporary: true)
} })
case .rcvInvitation: fileIcon("arrow.down", 10, 13) case .rcvInvitation: fileIcon("arrow.down", 10, 13)
case .rcvAccepted: fileIcon("ellipsis", 14, 11) case .rcvAccepted: fileIcon("ellipsis", 14, 11)
case let .rcvTransfer(rcvProgress, rcvTotal): case let .rcvTransfer(rcvProgress, rcvTotal):
@ -375,14 +375,14 @@ struct CIVideoView: View {
case .rcvCancelled: fileIcon("xmark", 10, 13) case .rcvCancelled: fileIcon("xmark", 10, 13)
case let .rcvError(rcvFileError): case let .rcvError(rcvFileError):
fileIcon("xmark", 10, 13) fileIcon("xmark", 10, 13)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError) showFileErrorAlert(rcvFileError)
} })
case let .rcvWarning(rcvFileError): case let .rcvWarning(rcvFileError):
fileIcon("exclamationmark.triangle.fill", 10, 13) fileIcon("exclamationmark.triangle.fill", 10, 13)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError, temporary: true) showFileErrorAlert(rcvFileError, temporary: true)
} })
case .invalid: fileIcon("questionmark", 10, 13) case .invalid: fileIcon("questionmark", 10, 13)
} }
} }
@ -429,7 +429,7 @@ struct CIVideoView: View {
Color.black.edgesIgnoringSafeArea(.all) Color.black.edgesIgnoringSafeArea(.all)
VideoPlayer(player: fullPlayer) VideoPlayer(player: fullPlayer)
.overlay(alignment: .topLeading, content: { .overlay(alignment: .topLeading, content: {
Button(action: { showFullScreenPlayer = false }, Button(action: { showFullScreenPlayer = false }, // this is used in full screen player, Button works here
label: { label: {
Image(systemName: "multiply") Image(systemName: "multiply")
.resizable() .resizable()

View file

@ -168,14 +168,14 @@ struct VoiceMessagePlayer: View {
case .sndCancelled: playbackButton() case .sndCancelled: playbackButton()
case let .sndError(sndFileError): case let .sndError(sndFileError):
fileStatusIcon("multiply", 14) fileStatusIcon("multiply", 14)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError) showFileErrorAlert(sndFileError)
} })
case let .sndWarning(sndFileError): case let .sndWarning(sndFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16) fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(sndFileError, temporary: true) showFileErrorAlert(sndFileError, temporary: true)
} })
case .rcvInvitation: downloadButton(recordingFile, "play.fill") case .rcvInvitation: downloadButton(recordingFile, "play.fill")
case .rcvAccepted: loadingIcon() case .rcvAccepted: loadingIcon()
case .rcvTransfer: loadingIcon() case .rcvTransfer: loadingIcon()
@ -184,14 +184,14 @@ struct VoiceMessagePlayer: View {
case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .rcvCancelled: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
case let .rcvError(rcvFileError): case let .rcvError(rcvFileError):
fileStatusIcon("multiply", 14) fileStatusIcon("multiply", 14)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError) showFileErrorAlert(rcvFileError)
} })
case let .rcvWarning(rcvFileError): case let .rcvWarning(rcvFileError):
fileStatusIcon("exclamationmark.triangle.fill", 16) fileStatusIcon("exclamationmark.triangle.fill", 16)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
showFileErrorAlert(rcvFileError, temporary: true) showFileErrorAlert(rcvFileError, temporary: true)
} })
case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) case .invalid: playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel))
} }
} else { } else {
@ -255,59 +255,29 @@ struct VoiceMessagePlayer: View {
} }
} }
@ViewBuilder private func playbackButton() -> some View { private func playbackButton() -> some View {
if sizeMultiplier != 1 { let icon = switch playbackState {
case .noPlayback: "play.fill"
case .playing: "pause.fill"
case .paused: "play.fill"
}
return playPauseIcon(icon, theme.colors.primary)
.simultaneousGesture(TapGesture().onEnded { _ in
switch playbackState { switch playbackState {
case .noPlayback: case .noPlayback:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
if let recordingSource = getLoadedFileSource(recordingFile) { if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource) startPlayback(recordingSource)
} }
}
case .playing: case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.pause() audioPlayer?.pause()
playbackState = .paused playbackState = .paused
notifyStateChange() notifyStateChange()
}
case .paused: case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play() audioPlayer?.play()
playbackState = .playing playbackState = .playing
notifyStateChange() notifyStateChange()
} }
} })
} else {
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
} label: {
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
notifyStateChange()
} label: {
playPauseIcon("play.fill", theme.colors.primary)
}
}
}
} }
private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View { private func playPauseIcon(_ image: String, _ color: Color/* = .accentColor*/) -> some View {
@ -329,28 +299,14 @@ struct VoiceMessagePlayer: View {
} }
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View { private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
Group {
if sizeMultiplier != 1 {
playPauseIcon(icon, theme.colors.primary) playPauseIcon(icon, theme.colors.primary)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
Task { Task {
if let user = chatModel.currentUser { if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId) await receiveFile(user: user, fileId: recordingFile.fileId)
} }
} }
} })
} else {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
}
}
}
} }
func notifyStateChange() { func notifyStateChange() {
@ -430,6 +386,7 @@ struct VoiceMessagePlayer: View {
} }
} }
@inline(__always)
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat { func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935 let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio) return squareSize + squareSize * (1 - squareToCircleRatio)
@ -446,10 +403,12 @@ class VoiceItemState {
self.playbackTime = playbackTime self.playbackTime = playbackTime
} }
@inline(__always)
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String { static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)" "\(chat.id) \(chatItem.id)"
} }
@inline(__always)
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String { static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)" "\(chatInfo.id) \(chatItem.id)"
} }

View file

@ -23,8 +23,6 @@ struct FramedItemView: View {
@State private var useWhiteMetaColor: Bool = false @State private var useWhiteMetaColor: Bool = false
@State var showFullScreenImage = false @State var showFullScreenImage = false
@Binding var allowMenu: Bool @Binding var allowMenu: Bool
@State private var showSecrets = false
@State private var showQuoteSecrets = false
@State private var showFullscreenGallery: Bool = false @State private var showFullscreenGallery: Bool = false
var body: some View { var body: some View {
@ -57,7 +55,7 @@ struct FramedItemView: View {
if let qi = chatItem.quotedItem { if let qi = chatItem.quotedItem {
ciQuoteView(qi) ciQuoteView(qi)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) {
withAnimation { withAnimation {
scrollToItemId(ci.id) scrollToItemId(ci.id)
@ -67,7 +65,7 @@ struct FramedItemView: View {
} else { } else {
showQuotedItemDoesNotExistAlert() showQuotedItemDoesNotExistAlert()
} }
} })
} else if let itemForwarded = chatItem.meta.itemForwarded { } else if let itemForwarded = chatItem.meta.itemForwarded {
framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true) framedItemHeader(icon: "arrowshape.turn.up.forward", caption: Text(itemForwarded.text(chat.chatInfo.chatType)).italic(), pad: true)
} }
@ -94,14 +92,14 @@ struct FramedItemView: View {
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo { if let (title, text) = chatItem.meta.itemStatus.statusInfo {
v.onTapGesture { v.simultaneousGesture(TapGesture().onEnded {
AlertManager.shared.showAlert( AlertManager.shared.showAlert(
Alert( Alert(
title: Text(title), title: Text(title),
message: Text(text) message: Text(text)
) )
) )
} })
} else { } else {
v v
} }
@ -159,7 +157,7 @@ struct FramedItemView: View {
case let .file(text): case let .file(text):
ciFileView(chatItem, text) ciFileView(chatItem, text)
case let .report(text, reason): case let .report(text, reason):
ciMsgContentView(chatItem, Text(text.isEmpty ? reason.text : "\(reason.text): ").italic().foregroundColor(.red)) ciMsgContentView(chatItem, txtPrefix: reason.attrString)
case let .link(_, preview): case let .link(_, preview):
CILinkView(linkPreview: preview) CILinkView(linkPreview: preview)
ciMsgContentView(chatItem) ciMsgContentView(chatItem)
@ -203,6 +201,7 @@ struct FramedItemView: View {
} }
@ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View { @ViewBuilder private func ciQuoteView(_ qi: CIQuote) -> some View {
let backgroundColor = chatItemFrameContextColor(chatItem, theme)
let v = ZStack(alignment: .topTrailing) { let v = ZStack(alignment: .topTrailing) {
switch (qi.content) { switch (qi.content) {
case let .image(_, image): case let .image(_, image):
@ -244,7 +243,8 @@ struct FramedItemView: View {
// if enable this always, size of the framed voice message item will be incorrect after end of playback // if enable this always, size of the framed voice message item will be incorrect after end of playback
.overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } }
.frame(minWidth: msgWidth, alignment: .leading) .frame(minWidth: msgWidth, alignment: .leading)
.background(chatItemFrameContextColor(chatItem, theme)) .background(backgroundColor)
.environment(\.containerBackground, UIColor(backgroundColor))
if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth { if let mediaWidth = maxMediaWidth(), mediaWidth < maxWidth {
v.frame(maxWidth: mediaWidth, alignment: .leading) v.frame(maxWidth: mediaWidth, alignment: .leading)
} else { } else {
@ -271,13 +271,11 @@ struct FramedItemView: View {
.padding(.horizontal, 12) .padding(.horizontal, 12)
} }
@inline(__always)
private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View { private func ciQuotedMsgTextView(_ qi: CIQuote, lines: Int) -> some View {
toggleSecrets(qi.formattedText, $showQuoteSecrets, MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, textStyle: .subheadline)
MsgContentView(chat: chat, text: qi.text, formattedText: qi.formattedText, showSecrets: showQuoteSecrets)
.lineLimit(lines) .lineLimit(lines)
.font(.subheadline)
.padding(.bottom, 6) .padding(.bottom, 6)
)
} }
private func ciQuoteIconView(_ image: String) -> some View { private func ciQuoteIconView(_ image: String) -> some View {
@ -297,21 +295,22 @@ struct FramedItemView: View {
} }
} }
@ViewBuilder private func ciMsgContentView(_ ci: ChatItem, _ txtPrefix: Text? = nil) -> some View { @ViewBuilder private func ciMsgContentView(_ ci: ChatItem, txtPrefix: NSAttributedString? = nil) -> some View {
let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text let text = ci.meta.isLive ? ci.content.msgContent?.text ?? ci.text : ci.text
let rtl = isRightToLeft(text) let rtl = isRightToLeft(text)
let ft = text == "" ? [] : ci.formattedText let ft = text == "" ? [] : ci.formattedText
let v = toggleSecrets(ft, $showSecrets, MsgContentView( let v = MsgContentView(
chat: chat, chat: chat,
text: text, text: text,
formattedText: ft, formattedText: ft,
textStyle: .body,
meta: ci.meta, meta: ci.meta,
mentions: ci.mentions, mentions: ci.mentions,
userMemberId: chat.chatInfo.groupInfo?.membership.memberId, userMemberId: chat.chatInfo.groupInfo?.membership.memberId,
rightToLeft: rtl, rightToLeft: rtl,
showSecrets: showSecrets,
prefix: txtPrefix prefix: txtPrefix
)) )
.environment(\.containerBackground, UIColor(chatItemFrameColor(ci, theme)))
.multilineTextAlignment(rtl ? .trailing : .leading) .multilineTextAlignment(rtl ? .trailing : .leading)
.padding(.vertical, 6) .padding(.vertical, 6)
.padding(.horizontal, 12) .padding(.horizontal, 12)
@ -351,14 +350,6 @@ struct FramedItemView: View {
} }
} }
@ViewBuilder func toggleSecrets<V: View>(_ ft: [FormattedText]?, _ showSecrets: Binding<Bool>, _ v: V) -> some View {
if let ft = ft, ft.contains(where: { $0.isSecret }) {
v.onTapGesture { showSecrets.wrappedValue.toggle() }
} else {
v
}
}
func isRightToLeft(_ s: String) -> Bool { func isRightToLeft(_ s: String) -> Bool {
if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) { if let lang = CFStringTokenizerCopyBestStringLanguage(s as CFString, CFRange(location: 0, length: min(s.count, 80))) {
return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft return NSLocale.characterDirection(forLanguage: lang as String) == .rightToLeft

View file

@ -126,7 +126,7 @@ struct FullScreenMediaView: View {
.scaledToFit() .scaledToFit()
} }
} }
.onTapGesture { showView = false } .onTapGesture { showView = false } // this is used in full screen view, onTapGesture works
} }
private func videoView( _ player: AVPlayer, _ url: URL) -> some View { private func videoView( _ player: AVPlayer, _ url: URL) -> some View {

View file

@ -31,8 +31,8 @@ struct IntegrityErrorItemView: View {
case .msgBadHash: case .msgBadHash:
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
title: Text("Bad message hash"), title: Text("Bad message hash"),
message: Text("The hash of the previous message is different.") + Text("\n") + message: Text("The hash of the previous message is different.") + textNewLine +
Text(decryptErrorReason) + Text("\n") + Text(decryptErrorReason) + textNewLine +
Text("Please report it to the developers.") Text("Please report it to the developers.")
)) ))
case .msgBadId: msgBadIdAlert() case .msgBadId: msgBadIdAlert()
@ -47,7 +47,7 @@ struct IntegrityErrorItemView: View {
message: Text(""" message: Text("""
The ID of the next message is incorrect (less or equal to the previous). The ID of the next message is incorrect (less or equal to the previous).
It can happen because of some bug or when the connection is compromised. It can happen because of some bug or when the connection is compromised.
""") + Text("\n") + """) + textNewLine +
Text("Please report it to the developers.") Text("Please report it to the developers.")
)) ))
} }
@ -71,7 +71,7 @@ struct CIMsgError: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) } .background { chatItemFrameColor(chatItem, theme).modifier(ChatTailPadding()) }
.textSelection(.disabled) .textSelection(.disabled)
.onTapGesture(perform: onTap) .simultaneousGesture(TapGesture().onEnded(onTap))
} }
} }

View file

@ -11,53 +11,74 @@ import SimpleXChat
let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1) let uiLinkColor = UIColor(red: 0, green: 0.533, blue: 1, alpha: 1)
private let noTyping = Text(verbatim: " ") private func typing(_ theme: AppTheme, _ descr: UIFontDescriptor, _ ws: [UIFont.Weight]) -> NSMutableAttributedString {
let res = NSMutableAttributedString()
private let typingIndicators: [Text] = [ for w in ws {
(typing(.black) + typing() + typing()), res.append(NSAttributedString(string: ".", attributes: [
(typing(.bold) + typing(.black) + typing()), .font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: w),
(typing() + typing(.bold) + typing(.black)), .kern: -2 as NSNumber,
(typing() + typing() + typing(.bold)) .foregroundColor: UIColor(theme.colors.secondary)
] ]))
}
private func typing(_ w: Font.Weight = .light) -> Text { return res
Text(".").fontWeight(w)
} }
struct MsgContentView: View { struct MsgContentView: View {
@ObservedObject var chat: Chat @ObservedObject var chat: Chat
@Environment(\.showTimestamp) var showTimestamp: Bool @Environment(\.showTimestamp) var showTimestamp: Bool
@Environment(\.containerBackground) var containerBackground: UIColor
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var text: String var text: String
var formattedText: [FormattedText]? = nil var formattedText: [FormattedText]? = nil
var textStyle: UIFont.TextStyle
var sender: String? = nil var sender: String? = nil
var meta: CIMeta? = nil var meta: CIMeta? = nil
var mentions: [String: CIMention]? = nil var mentions: [String: CIMention]? = nil
var userMemberId: String? = nil var userMemberId: String? = nil
var rightToLeft = false var rightToLeft = false
var showSecrets: Bool var prefix: NSAttributedString? = nil
var prefix: Text? = nil @State private var showSecrets: Set<Int> = []
@State private var typingIdx = 0 @State private var typingIdx = 0
@State private var timer: Timer? @State private var timer: Timer?
@State private var typingIndicators: [NSAttributedString] = []
@State private var noTyping = NSAttributedString(string: " ")
@State private var phase: CGFloat = 0
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false @AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
var body: some View { var body: some View {
let v = msgContentView()
if meta?.isLive == true { if meta?.isLive == true {
msgContentView() v.onAppear {
.onAppear { switchTyping() } let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
noTyping = NSAttributedString(string: " ", attributes: [
.font: UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular),
.kern: -2 as NSNumber,
.foregroundColor: UIColor(theme.colors.secondary)
])
switchTyping()
}
.onDisappear(perform: stopTyping) .onDisappear(perform: stopTyping)
.onChange(of: meta?.isLive, perform: switchTyping) .onChange(of: meta?.isLive, perform: switchTyping)
.onChange(of: meta?.recent, perform: switchTyping) .onChange(of: meta?.recent, perform: switchTyping)
} else { } else {
msgContentView() v
} }
} }
private func switchTyping(_: Bool? = nil) { private func switchTyping(_: Bool? = nil) {
if let meta = meta, meta.isLive && meta.recent { if let meta = meta, meta.isLive && meta.recent {
if typingIndicators.isEmpty {
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .body)
typingIndicators = [
typing(theme, descr, [.black, .light, .light]),
typing(theme, descr, [.bold, .black, .light]),
typing(theme, descr, [.light, .bold, .black]),
typing(theme, descr, [.light, .light, .bold])
]
}
timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in timer = timer ?? Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in
typingIdx = (typingIdx + 1) % typingIndicators.count typingIdx = typingIdx + 1
} }
} else { } else {
stopTyping() stopTyping()
@ -67,119 +88,276 @@ struct MsgContentView: View {
private func stopTyping() { private func stopTyping() {
timer?.invalidate() timer?.invalidate()
timer = nil timer = nil
typingIdx = 0
} }
private func msgContentView() -> Text { @inline(__always)
var v = messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary, prefix: prefix) private func msgContentView() -> some View {
let r = messageText(text, formattedText, textStyle: textStyle, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: containerBackground, prefix: prefix)
let s = r.string
let t: Text
if let mt = meta { if let mt = meta {
if mt.isLive { if mt.isLive {
v = v + typingIndicator(mt.recent) s.append(typingIndicator(mt.recent))
} }
v = v + reserveSpaceForMeta(mt) t = Text(AttributedString(s)) + reserveSpaceForMeta(mt)
} else {
t = Text(AttributedString(s))
} }
return v return msgTextResultView(r, t, showSecrets: $showSecrets)
} }
private func typingIndicator(_ recent: Bool) -> Text { @inline(__always)
return (recent ? typingIndicators[typingIdx] : noTyping) private func typingIndicator(_ recent: Bool) -> NSAttributedString {
.font(.body.monospaced()) recent && !typingIndicators.isEmpty
.kerning(-2) ? typingIndicators[typingIdx % 4]
.foregroundColor(theme.colors.secondary) : noTyping
} }
@inline(__always)
private func reserveSpaceForMeta(_ mt: CIMeta) -> Text { private func reserveSpaceForMeta(_ mt: CIMeta) -> Text {
(rightToLeft ? Text("\n") : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp) (rightToLeft ? textNewLine : Text(verbatim: " ")) + ciMetaText(mt, chatTTL: chat.chatInfo.timedMessagesTTL, encrypted: nil, colorMode: .transparent, showViaProxy: showSentViaProxy, showTimesamp: showTimestamp)
} }
} }
func messageText(_ text: String, _ formattedText: [FormattedText]?, _ sender: String?, icon: String? = nil, preview: Bool = false, mentions: [String: CIMention]?, userMemberId: String?, showSecrets: Bool, secondaryColor: Color, prefix: Text? = nil) -> Text { func msgTextResultView(_ r: MsgTextResult, _ t: Text, showSecrets: Binding<Set<Int>>? = nil) -> some View {
let s = text t.if(r.hasSecrets, transform: hiddenSecretsView)
var res: Text .if(r.handleTaps) { $0.overlay(handleTextTaps(r.string, showSecrets: showSecrets)) }
if let ft = formattedText, ft.count > 0 && ft.count <= 200 {
res = formatText(ft[0], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId)
var i = 1
while i < ft.count {
res = res + formatText(ft[i], preview, showSecret: showSecrets, mentions: mentions, userMemberId: userMemberId)
i = i + 1
} }
@inline(__always)
private func handleTextTaps(_ s: NSAttributedString, showSecrets: Binding<Set<Int>>? = nil) -> some View {
return GeometryReader { g in
Rectangle()
.fill(Color.clear)
.contentShape(Rectangle())
.simultaneousGesture(DragGesture(minimumDistance: 0).onEnded { event in
let t = event.translation
if t.width * t.width + t.height * t.height > 100 { return }
let framesetter = CTFramesetterCreateWithAttributedString(s as CFAttributedString)
let path = CGPath(rect: CGRect(origin: .zero, size: g.size), transform: nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRangeMake(0, s.length), path, nil)
let point = CGPoint(x: event.location.x, y: g.size.height - event.location.y) // Flip y for UIKit
var index: CFIndex?
if let lines = CTFrameGetLines(frame) as? [CTLine] {
var origins = [CGPoint](repeating: .zero, count: lines.count)
CTFrameGetLineOrigins(frame, CFRangeMake(0, 0), &origins)
for i in 0 ..< lines.count {
let bounds = CTLineGetBoundsWithOptions(lines[i], .useOpticalBounds)
if bounds.offsetBy(dx: origins[i].x, dy: origins[i].y).contains(point) {
index = CTLineGetStringIndexForPosition(lines[i], point)
break
}
}
}
if let index, let (url, browser) = attributedStringLink(s, for: index) {
if browser {
openBrowserAlert(uri: url)
} else { } else {
res = Text(s) UIApplication.shared.open(url)
}
}
})
} }
if let i = icon { func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (URL, Bool)? {
res = Text(Image(systemName: i)).foregroundColor(secondaryColor) + textSpace + res var linkURL: URL?
} var browser: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if let p = prefix { if index >= range.location && index < range.location + range.length {
res = p + res if let url = attrs[linkAttrKey] as? NSURL {
} linkURL = url.absoluteURL
browser = attrs[webLinkAttrKey] != nil
if let s = sender { } else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
let t = Text(s) if showSecrets.wrappedValue.contains(i) {
return (preview ? t : t.fontWeight(.medium)) + Text(": ") + res showSecrets.wrappedValue.remove(i)
} else { } else {
return res showSecrets.wrappedValue.insert(i)
}
}
stop.pointee = true
}
}
return if let linkURL { (linkURL, browser) } else { nil }
} }
} }
private func formatText(_ ft: FormattedText, _ preview: Bool, showSecret: Bool, mentions: [String: CIMention]?, userMemberId: String?) -> Text { func hiddenSecretsView<V: View>(_ v: V) -> some View {
let t = ft.text v.overlay(
if let f = ft.format { GeometryReader { g in
switch (f) { let size = (g.size.width + g.size.height) / 1.4142
case .bold: return Text(t).bold() Image("vertical_logo")
case .italic: return Text(t).italic() .resizable(resizingMode: .tile)
case .strikeThrough: return Text(t).strikethrough() .frame(width: size, height: size)
case .snippet: return Text(t).font(.body.monospaced()) .rotationEffect(.degrees(45), anchor: .center)
case .secret: return .position(x: g.size.width / 2, y: g.size.height / 2)
showSecret .clipped()
? Text(t) .saturation(0.65)
: Text(AttributedString(t, attributes: AttributeContainer([ .opacity(0.35)
.foregroundColor: UIColor.clear as Any, }
.backgroundColor: UIColor.secondarySystemFill as Any .mask(v)
]))) )
case let .colored(color): return Text(t).foregroundColor(color.uiColor) }
case .uri: return linkText(t, t, preview, prefix: "")
private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
typealias MsgTextResult = (string: NSMutableAttributedString, hasSecrets: Bool, handleTaps: Bool)
func messageText(
_ text: String,
_ formattedText: [FormattedText]?,
textStyle: UIFont.TextStyle = .body,
sender: String?,
preview: Bool = false,
mentions: [String: CIMention]?,
userMemberId: String?,
showSecrets: Set<Int>?,
backgroundColor: UIColor,
prefix: NSAttributedString? = nil
) -> MsgTextResult {
let res = NSMutableAttributedString()
let descr = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle)
let font = UIFont.preferredFont(forTextStyle: textStyle)
let plain: [NSAttributedString.Key: Any] = [
.font: font,
.foregroundColor: UIColor.label
]
let secretColor = backgroundColor.withAlphaComponent(1)
var link: [NSAttributedString.Key: Any]?
var hasSecrets = false
var handleTaps = false
if let sender {
if preview {
res.append(NSAttributedString(string: sender + ": ", attributes: plain))
} else {
var attrs = plain
attrs[.font] = UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.medium]]), size: descr.pointSize)
res.append(NSAttributedString(string: sender, attributes: attrs))
res.append(NSAttributedString(string: ": ", attributes: plain))
}
}
if let prefix {
res.append(prefix)
}
if let fts = formattedText, fts.count > 0 {
var bold: UIFont?
var italic: UIFont?
var snippet: UIFont?
var mention: UIFont?
var secretIdx: Int = 0
for ft in fts {
var t = ft.text
var attrs = plain
switch (ft.format) {
case .bold:
bold = bold ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.bold]]), size: descr.pointSize)
attrs[.font] = bold
case .italic:
italic = italic ?? UIFont(descriptor: descr.withSymbolicTraits(.traitItalic) ?? descr, size: descr.pointSize)
attrs[.font] = italic
case .strikeThrough:
attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
case .snippet:
snippet = snippet ?? UIFont.monospacedSystemFont(ofSize: descr.pointSize, weight: .regular)
attrs[.font] = snippet
case .secret:
if let showSecrets {
if !showSecrets.contains(secretIdx) {
attrs[.foregroundColor] = UIColor.clear
attrs[.backgroundColor] = secretColor
}
attrs[secretAttrKey] = secretIdx
secretIdx += 1
handleTaps = true
} else {
attrs[.foregroundColor] = UIColor.clear
attrs[.backgroundColor] = secretColor
}
hasSecrets = true
case let .colored(color):
if let c = color.uiColor {
attrs[.foregroundColor] = UIColor(c)
}
case .uri:
attrs = linkAttrs()
if !preview {
let s = t.lowercased()
let link = s.hasPrefix("http://") || s.hasPrefix("https://")
? t
: "https://" + t
attrs[linkAttrKey] = NSURL(string: link)
attrs[webLinkAttrKey] = true
handleTaps = true
}
case let .simplexLink(linkType, simplexUri, smpHosts): case let .simplexLink(linkType, simplexUri, smpHosts):
switch privacySimplexLinkModeDefault.get() { attrs = linkAttrs()
case .description: return linkText(simplexLinkText(linkType, smpHosts), simplexUri, preview, prefix: "") if !preview {
case .full: return linkText(t, simplexUri, preview, prefix: "") attrs[linkAttrKey] = NSURL(string: simplexUri)
case .browser: return linkText(t, simplexUri, preview, prefix: "") handleTaps = true
}
if case .description = privacySimplexLinkModeDefault.get() {
t = simplexLinkText(linkType, smpHosts)
} }
case let .mention(memberName): case let .mention(memberName):
if let m = mentions?[memberName] { if let m = mentions?[memberName] {
mention = mention ?? UIFont(descriptor: descr.addingAttributes([.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.semibold]]), size: descr.pointSize)
attrs[.font] = mention
if let ref = m.memberRef { if let ref = m.memberRef {
let name: String = if let alias = ref.localAlias, alias != "" { let name: String = if let alias = ref.localAlias, alias != "" {
"\(alias) (\(ref.displayName))" "\(alias) (\(ref.displayName))"
} else { } else {
ref.displayName ref.displayName
} }
let tName = mentionText(name) if m.memberId == userMemberId {
return m.memberId == userMemberId ? tName.foregroundColor(.accentColor) : tName attrs[.foregroundColor] = UIColor.tintColor
}
t = mentionText(name)
} else { } else {
return mentionText(memberName) t = mentionText(memberName)
} }
} }
return Text(t) case .email:
case .email: return linkText(t, t, preview, prefix: "mailto:") attrs = linkAttrs()
case .phone: return linkText(t, t.replacingOccurrences(of: " ", with: ""), preview, prefix: "tel:") if !preview {
attrs[linkAttrKey] = NSURL(string: "mailto:" + ft.text)
handleTaps = true
}
case .phone:
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = NSURL(string: "tel:" + t.replacingOccurrences(of: " ", with: ""))
handleTaps = true
}
case .none: ()
}
res.append(NSAttributedString(string: t, attributes: attrs))
} }
} else { } else {
return Text(t) res.append(NSMutableAttributedString(string: text, attributes: plain))
}
return (string: res, hasSecrets: hasSecrets, handleTaps: handleTaps)
func linkAttrs() -> [NSAttributedString.Key: Any] {
link = link ?? [
.font: font,
.foregroundColor: uiLinkColor,
.underlineStyle: NSUnderlineStyle.single.rawValue
]
return link!
} }
} }
private func mentionText(_ name: String) -> Text { @inline(__always)
Text(name.contains(" @") ? "@'\(name)'" : "@\(name)").fontWeight(.semibold) private func mentionText(_ name: String) -> String {
} name.contains(" @") ? "@'\(name)'" : "@\(name)"
private func linkText(_ s: String, _ link: String, _ preview: Bool, prefix: String, color: Color = Color(uiColor: uiLinkColor), uiColor: UIColor = uiLinkColor) -> Text {
preview
? Text(s).foregroundColor(color).underline(color: color)
: Text(AttributedString(s, attributes: AttributeContainer([
.link: NSURL(string: prefix + link) as Any,
.foregroundColor: uiColor as Any
]))).underline()
} }
func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String { func simplexLinkText(_ linkType: SimplexLinkType, _ smpHosts: [String]) -> String {
@ -193,9 +371,9 @@ struct MsgContentView_Previews: PreviewProvider {
chat: Chat.sampleData, chat: Chat.sampleData,
text: chatItem.text, text: chatItem.text,
formattedText: chatItem.formattedText, formattedText: chatItem.formattedText,
textStyle: .body,
sender: chatItem.memberDisplayName, sender: chatItem.memberDisplayName,
meta: chatItem.meta, meta: chatItem.meta
showSecrets: false
) )
.environmentObject(Chat.sampleData) .environmentObject(Chat.sampleData)
} }

View file

@ -41,7 +41,7 @@ struct ChatItemForwardingView: View {
.alert(item: $alert) { $0.alert } .alert(item: $alert) { $0.alert }
} }
@ViewBuilder private func forwardListView() -> some View { private func forwardListView() -> some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if !chatsToForwardTo.isEmpty { if !chatsToForwardTo.isEmpty {
List { List {

View file

@ -131,9 +131,9 @@ struct ChatItemInfoView: View {
} }
} }
@ViewBuilder private func details() -> some View { private func details() -> some View {
let meta = ci.meta let meta = ci.meta
VStack(alignment: .leading, spacing: 16) { return VStack(alignment: .leading, spacing: 16) {
Text(title) Text(title)
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
@ -197,7 +197,7 @@ struct ChatItemInfoView: View {
} }
} }
@ViewBuilder private func historyTab() -> some View { private func historyTab() -> some View {
GeometryReader { g in GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84 let maxWidth = (g.size.width - 32) * 0.84
ScrollView { ScrollView {
@ -227,12 +227,13 @@ struct ChatItemInfoView: View {
} }
} }
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View { private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
VStack(alignment: .leading, spacing: 4) { let backgroundColor = chatItemFrameColor(ci, theme)
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil) return VStack(alignment: .leading, spacing: 4) {
textBubble(itemVersion.msgContent.text, itemVersion.formattedText, nil, backgroundColor: UIColor(backgroundColor))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(chatItemFrameColor(ci, theme)) .background(backgroundColor)
.modifier(ChatItemClipped()) .modifier(ChatItemClipped())
.contextMenu { .contextMenu {
if itemVersion.msgContent.text != "" { if itemVersion.msgContent.text != "" {
@ -257,9 +258,9 @@ struct ChatItemInfoView: View {
.frame(maxWidth: maxWidth, alignment: .leading) .frame(maxWidth: maxWidth, alignment: .leading)
} }
@ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil) -> some View { @ViewBuilder private func textBubble(_ text: String, _ formattedText: [FormattedText]?, _ sender: String? = nil, backgroundColor: UIColor) -> some View {
if text != "" { if text != "" {
TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId) TextBubble(text: text, formattedText: formattedText, sender: sender, mentions: ci.mentions, userMemberId: userMemberId, backgroundColor: backgroundColor)
} else { } else {
Text("no text") Text("no text")
.italic() .italic()
@ -274,14 +275,16 @@ struct ChatItemInfoView: View {
var sender: String? = nil var sender: String? = nil
var mentions: [String: CIMention]? var mentions: [String: CIMention]?
var userMemberId: String? var userMemberId: String?
@State private var showSecrets = false var backgroundColor: UIColor
@State private var showSecrets: Set<Int> = []
var body: some View { var body: some View {
toggleSecrets(formattedText, $showSecrets, messageText(text, formattedText, sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, secondaryColor: theme.colors.secondary)) let r = messageText(text, formattedText, sender: sender, mentions: mentions, userMemberId: userMemberId, showSecrets: showSecrets, backgroundColor: backgroundColor)
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
} }
} }
@ViewBuilder private func quoteTab(_ qi: CIQuote) -> some View { private func quoteTab(_ qi: CIQuote) -> some View {
GeometryReader { g in GeometryReader { g in
let maxWidth = (g.size.width - 32) * 0.84 let maxWidth = (g.size.width - 32) * 0.84
ScrollView { ScrollView {
@ -299,9 +302,10 @@ struct ChatItemInfoView: View {
} }
} }
@ViewBuilder private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View { private func quotedMsgView(_ qi: CIQuote, _ maxWidth: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 4) { let backgroundColor = quotedMsgFrameColor(qi, theme)
textBubble(qi.text, qi.formattedText, qi.getSender(nil)) return VStack(alignment: .leading, spacing: 4) {
textBubble(qi.text, qi.formattedText, qi.getSender(nil), backgroundColor: UIColor(backgroundColor))
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.vertical, 6) .padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme)) .background(quotedMsgFrameColor(qi, theme))
@ -334,7 +338,7 @@ struct ChatItemInfoView: View {
: theme.appColors.receivedMessage : theme.appColors.receivedMessage
} }
@ViewBuilder private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View { private func forwardedFromTab(_ forwardedFromItem: AChatItem) -> some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
details() details()
@ -354,10 +358,11 @@ struct ChatItemInfoView: View {
Button { Button {
Task { Task {
await MainActor.run { await MainActor.run {
ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) ItemsModel.shared.loadOpenChat(forwardedFromItem.chatInfo.id) {
dismiss() dismiss()
} }
} }
}
} label: { } label: {
forwardedFromSender(forwardedFromItem) forwardedFromSender(forwardedFromItem)
} }
@ -371,7 +376,7 @@ struct ChatItemInfoView: View {
} }
} }
@ViewBuilder private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View { private func forwardedFromSender(_ forwardedFromItem: AChatItem) -> some View {
HStack { HStack {
ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48) ChatInfoImage(chat: Chat(chatInfo: forwardedFromItem.chatInfo), size: 48)
.padding(.trailing, 6) .padding(.trailing, 6)
@ -402,7 +407,7 @@ struct ChatItemInfoView: View {
} }
} }
@ViewBuilder private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { private func deliveryTab(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
details() details()
@ -417,7 +422,7 @@ struct ChatItemInfoView: View {
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
} }
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View { private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
LazyVStack(alignment: .leading, spacing: 12) { LazyVStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses) let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty { if !mss.isEmpty {

View file

@ -18,6 +18,10 @@ extension EnvironmentValues {
static let defaultValue: Bool = true static let defaultValue: Bool = true
} }
struct ContainerBackground: EnvironmentKey {
static let defaultValue: UIColor = .clear
}
var showTimestamp: Bool { var showTimestamp: Bool {
get { self[ShowTimestamp.self] } get { self[ShowTimestamp.self] }
set { self[ShowTimestamp.self] = newValue } set { self[ShowTimestamp.self] = newValue }
@ -27,6 +31,11 @@ extension EnvironmentValues {
get { self[Revealed.self] } get { self[Revealed.self] }
set { self[Revealed.self] = newValue } set { self[Revealed.self] = newValue }
} }
var containerBackground: UIColor {
get { self[ContainerBackground.self] }
set { self[ContainerBackground.self] = newValue }
}
} }
struct ChatItemView: View { struct ChatItemView: View {

View file

@ -60,6 +60,8 @@ func apiLoadMessages(
chatState.unreadTotal = chat.chatStats.unreadCount chatState.unreadTotal = chat.chatStats.unreadCount
chatState.unreadAfter = navInfo.afterUnread chatState.unreadAfter = navInfo.afterUnread
chatState.unreadAfterNewestLoaded = navInfo.afterUnread chatState.unreadAfterNewestLoaded = navInfo.afterUnread
PreloadState.shared.clear()
} }
case let .before(paginationChatItemId, _): case let .before(paginationChatItemId, _):
newItems.append(contentsOf: oldItems) newItems.append(contentsOf: oldItems)
@ -104,19 +106,22 @@ func apiLoadMessages(
} }
} }
case .around: case .around:
let newSplits: [Int64] var newSplits: [Int64]
if openAroundItemId == nil { if openAroundItemId == nil {
newItems.append(contentsOf: oldItems) newItems.append(contentsOf: oldItems)
newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed) newSplits = await removeDuplicatesAndUpperSplits(&newItems, chat, chatState.splits, visibleItemIndexesNonReversed)
} else { } else {
newSplits = [] newSplits = []
} }
// currently, items will always be added on top, which is index 0 let (itemIndex, splitIndex) = indexToInsertAround(chat.chatInfo.chatType, chat.chatItems.last, to: newItems, Set(newSplits))
newItems.insert(contentsOf: chat.chatItems, at: 0) //indexToInsertAroundTest()
newItems.insert(contentsOf: chat.chatItems, at: itemIndex)
newSplits.insert(chat.chatItems.last!.id, at: splitIndex)
let newReversed: [ChatItem] = newItems.reversed() let newReversed: [ChatItem] = newItems.reversed()
let orderedSplits = newSplits
await MainActor.run { await MainActor.run {
ItemsModel.shared.reversedChatItems = newReversed ItemsModel.shared.reversedChatItems = newReversed
chatState.splits = [chat.chatItems.last!.id] + newSplits chatState.splits = orderedSplits
chatState.unreadAfterItemId = chat.chatItems.last!.id chatState.unreadAfterItemId = chat.chatItems.last!.id
chatState.totalAfter = navInfo.afterTotal chatState.totalAfter = navInfo.afterTotal
chatState.unreadTotal = chat.chatStats.unreadCount chatState.unreadTotal = chat.chatStats.unreadCount
@ -130,14 +135,16 @@ func apiLoadMessages(
// no need to set it, count will be wrong // no need to set it, count will be wrong
// chatState.unreadAfterNewestLoaded = navInfo.afterUnread // chatState.unreadAfterNewestLoaded = navInfo.afterUnread
} }
PreloadState.shared.clear()
} }
case .last: case .last:
newItems.append(contentsOf: oldItems) newItems.append(contentsOf: oldItems)
removeDuplicates(&newItems, chat) let newSplits = await removeDuplicatesAndUnusedSplits(&newItems, chat, chatState.splits)
newItems.append(contentsOf: chat.chatItems) newItems.append(contentsOf: chat.chatItems)
let items = newItems let items = newItems
await MainActor.run { await MainActor.run {
ItemsModel.shared.reversedChatItems = items.reversed() ItemsModel.shared.reversedChatItems = items.reversed()
chatState.splits = newSplits
chatModel.updateChatInfo(chat.chatInfo) chatModel.updateChatInfo(chat.chatInfo)
chatState.unreadAfterNewestLoaded = 0 chatState.unreadAfterNewestLoaded = 0
} }
@ -234,10 +241,14 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId) let indexInSplitRanges = splits.firstIndex(of: paginationChatItemId)
// Currently, it should always load from split range // Currently, it should always load from split range
let loadingFromSplitRange = indexInSplitRanges != nil let loadingFromSplitRange = indexInSplitRanges != nil
var splitsToMerge: [Int64] = if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count { let topSplits: [Int64]
Array(splits[indexInSplitRanges + 1 ..< splits.count]) var splitsToMerge: [Int64]
if let indexInSplitRanges, loadingFromSplitRange && indexInSplitRanges + 1 <= splits.count {
splitsToMerge = Array(splits[indexInSplitRanges + 1 ..< splits.count])
topSplits = Array(splits[0 ..< indexInSplitRanges + 1])
} else { } else {
[] splitsToMerge = []
topSplits = []
} }
newItems.removeAll(where: { new in newItems.removeAll(where: { new in
let duplicate = newIds.contains(new.id) let duplicate = newIds.contains(new.id)
@ -257,8 +268,8 @@ private func removeDuplicatesAndModifySplitsOnAfterPagination(
}) })
var newSplits: [Int64] = [] var newSplits: [Int64] = []
if firstItemIdBelowAllSplits != nil { if firstItemIdBelowAllSplits != nil {
// no splits anymore, all were merged with bottom items // no splits below anymore, all were merged with bottom items
newSplits = [] newSplits = topSplits
} else { } else {
if !splitsToRemove.isEmpty { if !splitsToRemove.isEmpty {
var new = splits var new = splits
@ -320,6 +331,28 @@ private func removeDuplicatesAndUpperSplits(
return newSplits return newSplits
} }
private func removeDuplicatesAndUnusedSplits(
_ newItems: inout [ChatItem],
_ chat: Chat,
_ splits: [Int64]
) async -> [Int64] {
if splits.isEmpty {
removeDuplicates(&newItems, chat)
return splits
}
var newSplits = splits
let (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll(where: {
let duplicate = newIds.contains($0.id)
if duplicate, let firstIndex = newSplits.firstIndex(of: $0.id) {
newSplits.remove(at: firstIndex)
}
return duplicate
})
return newSplits
}
// ids, number of unread items // ids, number of unread items
private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) { private func mapItemsToIds(_ items: [ChatItem]) -> (Set<Int64>, Int) {
var unreadInLoaded = 0 var unreadInLoaded = 0
@ -340,3 +373,139 @@ private func removeDuplicates(_ newItems: inout [ChatItem], _ chat: Chat) {
let (newIds, _) = mapItemsToIds(chat.chatItems) let (newIds, _) = mapItemsToIds(chat.chatItems)
newItems.removeAll { newIds.contains($0.id) } newItems.removeAll { newIds.contains($0.id) }
} }
private typealias SameTimeItem = (index: Int, item: ChatItem)
// return (item index, split index)
private func indexToInsertAround(_ chatType: ChatType, _ lastNew: ChatItem?, to: [ChatItem], _ splits: Set<Int64>) -> (Int, Int) {
guard to.count > 0, let lastNew = lastNew else { return (0, 0) }
// group sorting: item_ts, item_id
// everything else: created_at, item_id
let compareByTimeTs = chatType == .group
// in case several items have the same time as another item in the `to` array
var sameTime: [SameTimeItem] = []
// trying to find new split index for item looks difficult but allows to not use one more loop.
// The idea is to memorize how many splits were till any index (map number of splits until index)
// and use resulting itemIndex to decide new split index position.
// Because of the possibility to have many items with the same timestamp, it's possible to see `itemIndex < || == || > i`.
var splitsTillIndex: [Int] = []
var splitsPerPrevIndex = 0
for i in 0 ..< to.count {
let item = to[i]
splitsPerPrevIndex = splits.contains(item.id) ? splitsPerPrevIndex + 1 : splitsPerPrevIndex
splitsTillIndex.append(splitsPerPrevIndex)
let itemIsNewer = (compareByTimeTs ? item.meta.itemTs > lastNew.meta.itemTs : item.meta.createdAt > lastNew.meta.createdAt)
if itemIsNewer || i + 1 == to.count {
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
sameTime.append((i, item))
}
// time to stop the loop. Item is newer or it's the last item in `to` array, taking previous items and checking position inside them
let itemIndex: Int
if sameTime.count > 1, let first = sameTime.sorted(by: { prev, next in prev.item.meta.itemId < next.item.id }).first(where: { same in same.item.id > lastNew.id }) {
itemIndex = first.index
} else if sameTime.count == 1 {
itemIndex = sameTime[0].item.id > lastNew.id ? sameTime[0].index : sameTime[0].index + 1
} else {
itemIndex = itemIsNewer ? i : i + 1
}
let splitIndex = splitsTillIndex[min(itemIndex, splitsTillIndex.count - 1)]
let prevItemSplitIndex = itemIndex == 0 ? 0 : splitsTillIndex[min(itemIndex - 1, splitsTillIndex.count - 1)]
return (itemIndex, splitIndex == prevItemSplitIndex ? splitIndex : prevItemSplitIndex)
}
if (compareByTimeTs ? lastNew.meta.itemTs == item.meta.itemTs : lastNew.meta.createdAt == item.meta.createdAt) {
sameTime.append(SameTimeItem(index: i, item: item))
} else {
sameTime = []
}
}
// shouldn't be here
return (to.count, splits.count)
}
private func indexToInsertAroundTest() {
func assert(_ one: (Int, Int), _ two: (Int, Int)) {
if one != two {
logger.debug("\(String(describing: one)) != \(String(describing: two))")
fatalError()
}
}
let itemsToInsert = [ChatItem.getSample(3, .groupSnd, Date.init(timeIntervalSince1970: 3), "")]
let items1 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 2), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items1, Set([1])), (3, 1))
let items2 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 1), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items2, Set([2])), (3, 1))
let items3 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(1, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items3, Set([1])), (3, 1))
let items4 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items4, Set([4])), (1, 0))
let items5 = [
ChatItem.getSample(0, .groupSnd, Date.init(timeIntervalSince1970: 0), ""),
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items5, Set([2])), (2, 1))
let items6 = [
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items6, Set([5])), (0, 0))
let items7 = [
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, nil, to: items7, Set([6])), (0, 0))
let items8 = [
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 4), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items8, Set([2])), (0, 0))
let items9 = [
ChatItem.getSample(2, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items9, Set([5])), (1, 0))
let items10 = [
ChatItem.getSample(4, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(5, .groupSnd, Date.init(timeIntervalSince1970: 3), ""),
ChatItem.getSample(6, .groupSnd, Date.init(timeIntervalSince1970: 4), "")
]
assert(indexToInsertAround(.group, itemsToInsert.last, to: items10, Set([4])), (0, 0))
let items11: [ChatItem] = []
assert(indexToInsertAround(.group, itemsToInsert.last, to: items11, Set([])), (0, 0))
}

View file

@ -321,6 +321,101 @@ class ActiveChatState {
unreadAfter = 0 unreadAfter = 0
unreadAfterNewestLoaded = 0 unreadAfterNewestLoaded = 0
} }
func itemsRead(_ itemIds: Set<Int64>?, _ newItems: [ChatItem]) {
guard let itemIds else {
// special case when the whole chat became read
unreadTotal = 0
unreadAfter = 0
return
}
var unreadAfterItemIndex: Int = -1
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
var i = newItems.count - 1
var ids = itemIds
// intermediate variables to prevent re-setting state value a lot of times without reason
var newUnreadTotal = unreadTotal
var newUnreadAfter = unreadAfter
while i >= 0 {
let item = newItems[i]
if item.id == unreadAfterItemId {
unreadAfterItemIndex = i
}
if ids.contains(item.id) {
// was unread, now this item is read
if (unreadAfterItemIndex == -1) {
newUnreadAfter -= 1
}
newUnreadTotal -= 1
ids.remove(item.id)
if ids.isEmpty {
break
}
}
i -= 1
}
unreadTotal = newUnreadTotal
unreadAfter = newUnreadAfter
}
func itemAdded(_ item: (Int64, Bool), _ index: Int) {
if item.1 {
unreadAfter += 1
unreadTotal += 1
}
}
func itemsRemoved(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) {
var newSplits: [Int64] = []
for split in splits {
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split })
// deleted the item that was right before the split between items, find newer item so it will act like the split
if let index {
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
// it the whole section is gone and splits overlap, don't add it at all
if let newSplit, !newSplits.contains(newSplit) {
newSplits.append(newSplit)
}
} else {
newSplits.append(split)
}
}
splits = newSplits
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == unreadAfterItemId })
// unread after item was removed
if let index {
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil
if newUnreadAfterItemId == nil {
// everything on top (including unread after item) were deleted, take top item as unread after id
newUnreadAfterItemId = newItems.first?.id
}
if let newUnreadAfterItemId {
unreadAfterItemId = newUnreadAfterItemId
totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count
unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count
unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count
if newUnreadAfterItemWasNull {
// since the unread after item was moved one item after initial position, adjust counters accordingly
if newItems.first?.isRcvNew == true {
unreadTotal += 1
unreadAfter -= 1
}
}
} else {
// all items were deleted, 0 items in chatItems
unreadAfterItemId = -1
totalAfter = 0
unreadTotal = 0
unreadAfter = 0
}
} else {
totalAfter -= itemIds.count
}
}
} }
class BoxedValue<T: Hashable>: Equatable, Hashable { class BoxedValue<T: Hashable>: Equatable, Hashable {
@ -359,101 +454,3 @@ func visibleItemIndexesNonReversed(_ listState: EndlessScrollView<MergedItem>.Li
// visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed() // visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed()
return range return range
} }
class RecalculatePositions {
private var chatState: ActiveChatState { get { ItemsModel.shared.chatState } }
func read(_ itemIds: Set<Int64>?, _ newItems: [ChatItem]) {
guard let itemIds else {
// special case when the whole chat became read
chatState.unreadTotal = 0
chatState.unreadAfter = 0
return
}
var unreadAfterItemIndex: Int = -1
// since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster
var i = newItems.count - 1
var ids = itemIds
// intermediate variables to prevent re-setting state value a lot of times without reason
var newUnreadTotal = chatState.unreadTotal
var newUnreadAfter = chatState.unreadAfter
while i >= 0 {
let item = newItems[i]
if item.id == chatState.unreadAfterItemId {
unreadAfterItemIndex = i
}
if ids.contains(item.id) {
// was unread, now this item is read
if (unreadAfterItemIndex == -1) {
newUnreadAfter -= 1
}
newUnreadTotal -= 1
ids.remove(item.id)
if ids.isEmpty {
break
}
}
i -= 1
}
chatState.unreadTotal = newUnreadTotal
chatState.unreadAfter = newUnreadAfter
}
func added(_ item: (Int64, Bool), _ index: Int) {
if item.1 {
chatState.unreadAfter += 1
chatState.unreadTotal += 1
}
}
func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) {
var newSplits: [Int64] = []
for split in chatState.splits {
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split })
// deleted the item that was right before the split between items, find newer item so it will act like the split
if let index {
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
// it the whole section is gone and splits overlap, don't add it at all
if let newSplit, !newSplits.contains(newSplit) {
newSplits.append(newSplit)
}
} else {
newSplits.append(split)
}
}
chatState.splits = newSplits
let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId })
// unread after item was removed
if let index {
let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count
var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil
let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil
if newUnreadAfterItemId == nil {
// everything on top (including unread after item) were deleted, take top item as unread after id
newUnreadAfterItemId = newItems.first?.id
}
if let newUnreadAfterItemId {
chatState.unreadAfterItemId = newUnreadAfterItemId
chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count
chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count
chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count
if newUnreadAfterItemWasNull {
// since the unread after item was moved one item after initial position, adjust counters accordingly
if newItems.first?.isRcvNew == true {
chatState.unreadTotal += 1
chatState.unreadAfter -= 1
}
}
} else {
// all items were deleted, 0 items in chatItems
chatState.unreadAfterItemId = -1
chatState.totalAfter = 0
chatState.unreadTotal = 0
chatState.unreadAfter = 0
}
} else {
chatState.totalAfter -= itemIds.count
}
}
func cleared() { chatState.clear() }
}

View file

@ -9,17 +9,16 @@
import SwiftUI import SwiftUI
import SimpleXChat import SimpleXChat
func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) { func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Binding<Bool>, _ chat: Chat) async {
if ItemsModel.shared.chatState.totalAfter == 0 { await MainActor.run {
return
}
loadingMoreItems.wrappedValue = true loadingMoreItems.wrappedValue = true
loadingBottomItems.wrappedValue = true loadingBottomItems.wrappedValue = true
Task { }
try? await Task.sleep(nanoseconds: 500_000000) try? await Task.sleep(nanoseconds: 500_000000)
if ChatModel.shared.chatId != chat.chatInfo.id { if ChatModel.shared.chatId != chat.chatInfo.id {
await MainActor.run { await MainActor.run {
loadingMoreItems.wrappedValue = false loadingMoreItems.wrappedValue = false
loadingBottomItems.wrappedValue = false
} }
return return
} }
@ -29,13 +28,18 @@ func loadLastItems(_ loadingMoreItems: Binding<Bool>, loadingBottomItems: Bindin
loadingBottomItems.wrappedValue = false loadingBottomItems.wrappedValue = false
} }
} }
}
class PreloadState { class PreloadState {
static let shared = PreloadState() static let shared = PreloadState()
var prevFirstVisible: Int64 = Int64.min var prevFirstVisible: Int64 = Int64.min
var prevItemsCount: Int = 0 var prevItemsCount: Int = 0
var preloading: Bool = false var preloading: Bool = false
func clear() {
prevFirstVisible = Int64.min
prevItemsCount = 0
preloading = false
}
} }
func preloadIfNeeded( func preloadIfNeeded(
@ -43,26 +47,41 @@ func preloadIfNeeded(
_ ignoreLoadingRequests: Binding<Int64?>, _ ignoreLoadingRequests: Binding<Int64?>,
_ listState: EndlessScrollView<MergedItem>.ListState, _ listState: EndlessScrollView<MergedItem>.ListState,
_ mergedItems: BoxedValue<MergedItems>, _ mergedItems: BoxedValue<MergedItems>,
loadItems: @escaping (Bool, ChatPagination) async -> Bool loadItems: @escaping (Bool, ChatPagination) async -> Bool,
loadLastItems: @escaping () async -> Void
) { ) {
let state = PreloadState.shared let state = PreloadState.shared
guard !listState.isScrolling && !listState.isAnimatedScrolling, guard !listState.isScrolling && !listState.isAnimatedScrolling,
state.prevFirstVisible != listState.firstVisibleItemIndex || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count,
!state.preloading, !state.preloading,
listState.totalItemsCount > 0 listState.totalItemsCount > 0
else { else {
return return
} }
state.prevFirstVisible = listState.firstVisibleItemId as! Int64 if state.prevFirstVisible != listState.firstVisibleItemId as! Int64 || state.prevItemsCount != mergedItems.boxedValue.indexInParentItems.count {
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
state.preloading = true state.preloading = true
let allowLoadMore = allowLoadMoreItems.wrappedValue let allowLoadMore = allowLoadMoreItems.wrappedValue
Task { Task {
defer { defer { state.preloading = false }
state.preloading = false var triedToLoad = true
}
await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in await preloadItems(mergedItems.boxedValue, allowLoadMore, listState, ignoreLoadingRequests) { pagination in
await loadItems(false, pagination) triedToLoad = await loadItems(false, pagination)
return triedToLoad
}
if triedToLoad {
state.prevFirstVisible = listState.firstVisibleItemId as! Int64
state.prevItemsCount = mergedItems.boxedValue.indexInParentItems.count
}
// it's important to ask last items when the view is fully covered with items. Otherwise, visible items from one
// split will be merged with last items and position of scroll will change unexpectedly.
if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
await loadLastItems()
}
}
} else if listState.itemsCanCoverScreen && !ItemsModel.shared.lastItemsLoaded {
state.preloading = true
Task {
defer { state.preloading = false }
await loadLastItems()
} }
} }
} }
@ -105,6 +124,7 @@ async {
let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT)) let triedToLoad = await loadItems(ChatPagination.before(chatItemId: loadFromItemId, count: ChatPagination.PRELOAD_COUNT))
if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id { if triedToLoad && sizeWas == ItemsModel.shared.reversedChatItems.count && firstItemIdWas == ItemsModel.shared.reversedChatItems.last?.id {
ignoreLoadingRequests.wrappedValue = loadFromItemId ignoreLoadingRequests.wrappedValue = loadFromItemId
return false
} }
return triedToLoad return triedToLoad
} }

View file

@ -45,7 +45,7 @@ struct ChatView: View {
@State private var selectedMember: GMember? = nil @State private var selectedMember: GMember? = nil
// opening GroupLinkView on link button (incognito) // opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false @State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String? @State private var groupLink: CreatedConnLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var forwardedChatItems: [ChatItem] = [] @State private var forwardedChatItems: [ChatItem] = []
@State private var selectedChatItems: Set<Int64>? = nil @State private var selectedChatItems: Set<Int64>? = nil
@ -71,10 +71,9 @@ struct ChatView: View {
} }
} }
@ViewBuilder
private var viewBody: some View { private var viewBody: some View {
let cInfo = chat.chatInfo let cInfo = chat.chatInfo
ZStack { return ZStack {
let wallpaperImage = theme.wallpaper.type.image let wallpaperImage = theme.wallpaper.type.image
let wallpaperType = theme.wallpaper.type let wallpaperType = theme.wallpaper.type
let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background) let backgroundColor = theme.wallpaper.background ?? wallpaperType.defaultBackgroundColor(theme.base, theme.colors.background)
@ -91,18 +90,32 @@ struct ChatView: View {
if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty { if let groupInfo = chat.chatInfo.groupInfo, !composeState.message.isEmpty {
GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible) GroupMentionsView(groupInfo: groupInfo, composeState: $composeState, selectedRange: $selectedRange, keyboardVisible: $keyboardVisible)
} }
FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel) FloatingButtons(theme: theme, scrollView: scrollView, chat: chat, loadingMoreItems: $loadingMoreItems, loadingTopItems: $loadingTopItems, requestedTopScroll: $requestedTopScroll, loadingBottomItems: $loadingBottomItems, requestedBottomScroll: $requestedBottomScroll, animatedScrollingInProgress: $animatedScrollingInProgress, listState: scrollView.listState, model: floatingButtonModel, reloadItems: {
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
scrollView.updateItems(mergedItems.boxedValue.items)
}
)
} }
connectingText() connectingText()
if selectedChatItems == nil { if selectedChatItems == nil {
let reason = chat.chatInfo.userCantSendReason
ComposeView( ComposeView(
chat: chat, chat: chat,
composeState: $composeState, composeState: $composeState,
keyboardVisible: $keyboardVisible, keyboardVisible: $keyboardVisible,
keyboardHiddenDate: $keyboardHiddenDate, keyboardHiddenDate: $keyboardHiddenDate,
selectedRange: $selectedRange selectedRange: $selectedRange,
disabledText: reason?.composeLabel
) )
.disabled(!cInfo.sendMsgEnabled) .disabled(!cInfo.sendMsgEnabled)
.if(!cInfo.sendMsgEnabled) { v in
v.disabled(true).onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: reason?.alertMessage
)
}
}
} else { } else {
SelectedItemsBottomToolbar( SelectedItemsBottomToolbar(
chatItems: ItemsModel.shared.reversedChatItems, chatItems: ItemsModel.shared.reversedChatItems,
@ -262,7 +275,6 @@ struct ChatView: View {
// this may already being loading because of changed chat id (see .onChange(of: chat.id) // this may already being loading because of changed chat id (see .onChange(of: chat.id)
if !loadingBottomItems { if !loadingBottomItems {
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
allowLoadMoreItems = false allowLoadMoreItems = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true allowLoadMoreItems = true
@ -278,7 +290,7 @@ struct ChatView: View {
if chatModel.chatId == nil { if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:] chatModel.chatItemStatuses = [:]
ItemsModel.shared.reversedChatItems = [] ItemsModel.shared.reversedChatItems = []
ItemsModel.shared.chatItemsChangesListener.cleared() ItemsModel.shared.chatState.clear()
chatModel.groupMembers = [] chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll() chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false chatModel.membersLoaded = false
@ -494,7 +506,7 @@ struct ChatView: View {
Button ("Cancel") { Button ("Cancel") {
closeSearch() closeSearch()
Task { await loadChat(chat: chat) } searchTextChanged("")
} }
} }
.padding(.horizontal) .padding(.horizontal)
@ -570,13 +582,20 @@ struct ChatView: View {
.onChange(of: im.reversedChatItems) { items in .onChange(of: im.reversedChatItems) { items in
mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState) mergedItems.boxedValue = MergedItems.create(items, revealedItems, im.chatState)
scrollView.updateItems(mergedItems.boxedValue.items) scrollView.updateItems(mergedItems.boxedValue.items)
if im.itemAdded {
im.itemAdded = false
if scrollView.listState.firstVisibleItemIndex < 2 {
scrollView.scrollToBottomAnimated()
} else {
scrollView.scroll(by: 34)
}
}
} }
.onChange(of: revealedItems) { revealed in .onChange(of: revealedItems) { revealed in
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState) mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealed, im.chatState)
scrollView.updateItems(mergedItems.boxedValue.items) scrollView.updateItems(mergedItems.boxedValue.items)
} }
.onChange(of: chat.id) { _ in .onChange(of: chat.id) { _ in
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
allowLoadMoreItems = false allowLoadMoreItems = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true allowLoadMoreItems = true
@ -585,33 +604,8 @@ struct ChatView: View {
.padding(.vertical, -100) .padding(.vertical, -100)
.onTapGesture { hideKeyboard() } .onTapGesture { hideKeyboard() }
.onChange(of: searchText) { s in .onChange(of: searchText) { s in
guard showSearch else { return } if showSearch {
Task { searchTextChanged(s)
await loadChat(chat: chat, search: s)
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
await MainActor.run {
scrollView.updateItems(mergedItems.boxedValue.items)
}
if !s.isEmpty {
scrollView.scrollToBottom()
} else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
// scroll to the top unread item
scrollView.scrollToItem(index)
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
} else {
scrollView.scrollToBottom()
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
}
}
}
.onChange(of: im.itemAdded) { added in
if added {
im.itemAdded = false
if scrollView.listState.firstVisibleItemIndex < 2 {
scrollView.scrollToBottomAnimated()
} else {
scrollView.scroll(by: 34)
}
} }
} }
} }
@ -646,12 +640,29 @@ struct ChatView: View {
if let unreadIndex { if let unreadIndex {
scrollView.scrollToItem(unreadIndex) scrollView.scrollToItem(unreadIndex)
} }
loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
allowLoadMoreItems = true allowLoadMoreItems = true
} }
} }
private func searchTextChanged(_ s: String) {
Task {
await loadChat(chat: chat, search: s)
mergedItems.boxedValue = MergedItems.create(im.reversedChatItems, revealedItems, im.chatState)
await MainActor.run {
scrollView.updateItems(mergedItems.boxedValue.items)
}
if !s.isEmpty {
scrollView.scrollToBottom()
} else if let index = scrollView.listState.items.lastIndex(where: { $0.hasUnread() }) {
// scroll to the top unread item
scrollView.scrollToItem(index)
} else {
scrollView.scrollToBottom()
}
}
}
class FloatingButtonModel: ObservableObject { class FloatingButtonModel: ObservableObject {
@Published var unreadAbove: Int = 0 @Published var unreadAbove: Int = 0
@Published var unreadBelow: Int = 0 @Published var unreadBelow: Int = 0
@ -728,6 +739,7 @@ struct ChatView: View {
let theme: AppTheme let theme: AppTheme
let scrollView: EndlessScrollView<MergedItem> let scrollView: EndlessScrollView<MergedItem>
let chat: Chat let chat: Chat
@Binding var loadingMoreItems: Bool
@Binding var loadingTopItems: Bool @Binding var loadingTopItems: Bool
@Binding var requestedTopScroll: Bool @Binding var requestedTopScroll: Bool
@Binding var loadingBottomItems: Bool @Binding var loadingBottomItems: Bool
@ -735,6 +747,7 @@ struct ChatView: View {
@Binding var animatedScrollingInProgress: Bool @Binding var animatedScrollingInProgress: Bool
let listState: EndlessScrollView<MergedItem>.ListState let listState: EndlessScrollView<MergedItem>.ListState
@ObservedObject var model: FloatingButtonModel @ObservedObject var model: FloatingButtonModel
let reloadItems: () -> Void
var body: some View { var body: some View {
ZStack(alignment: .top) { ZStack(alignment: .top) {
@ -792,7 +805,7 @@ struct ChatView: View {
} }
} }
.onTapGesture { .onTapGesture {
if loadingBottomItems { if loadingBottomItems || !ItemsModel.shared.lastItemsLoaded {
requestedTopScroll = false requestedTopScroll = false
requestedBottomScroll = true requestedBottomScroll = true
} else { } else {
@ -812,7 +825,7 @@ struct ChatView: View {
} }
} }
.onChange(of: loadingBottomItems) { loading in .onChange(of: loadingBottomItems) { loading in
if !loading && requestedBottomScroll { if !loading && requestedBottomScroll && ItemsModel.shared.lastItemsLoaded {
requestedBottomScroll = false requestedBottomScroll = false
scrollToBottom() scrollToBottom()
} }
@ -821,17 +834,27 @@ struct ChatView: View {
} }
private func scrollToTopUnread() { private func scrollToTopUnread() {
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
animatedScrollingInProgress = true
// scroll to the top unread item
Task { Task {
if !ItemsModel.shared.chatState.splits.isEmpty {
await MainActor.run { loadingMoreItems = true }
await loadChat(chatId: chat.id, openAroundItemId: nil, clearItems: false)
await MainActor.run { reloadItems() }
if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
await MainActor.run { animatedScrollingInProgress = true }
await scrollView.scrollToItemAnimated(index) await scrollView.scrollToItemAnimated(index)
await MainActor.run { animatedScrollingInProgress = false } await MainActor.run { animatedScrollingInProgress = false }
} }
await MainActor.run { loadingMoreItems = false }
} else if let index = listState.items.lastIndex(where: { $0.hasUnread() }) {
await MainActor.run { animatedScrollingInProgress = true }
// scroll to the top unread item
await scrollView.scrollToItemAnimated(index)
await MainActor.run { animatedScrollingInProgress = false }
} else { } else {
logger.debug("No more unread items, total: \(listState.items.count)") logger.debug("No more unread items, total: \(listState.items.count)")
} }
} }
}
private func scrollToBottom() { private func scrollToBottom() {
animatedScrollingInProgress = true animatedScrollingInProgress = true
@ -1144,6 +1167,11 @@ struct ChatView: View {
} else { } else {
await loadChatItems(chat, pagination) await loadChatItems(chat, pagination)
} }
},
loadLastItems: {
if !loadingMoreItems {
await loadLastItems($loadingMoreItems, loadingBottomItems: $loadingBottomItems, chat)
}
} }
) )
} }
@ -1244,18 +1272,11 @@ struct ChatView: View {
nil nil
} }
let showAvatar = shouldShowAvatar(item, listItem.nextItem) let showAvatar = shouldShowAvatar(item, listItem.nextItem)
let itemSeparation: ItemSeparation
let single = switch merged { let single = switch merged {
case .single: true case .single: true
default: false default: false
} }
if single || revealed { let itemSeparation = getItemSeparation(item, single || revealed ? listItem.prevItem: nil)
let prev = listItem.prevItem
itemSeparation = getItemSeparation(item, prev)
let nextForGap = (item.mergeCategory != nil && item.mergeCategory == prev?.mergeCategory) || isLastItem ? nil : listItem.nextItem
} else {
itemSeparation = getItemSeparation(item, nil)
}
return VStack(spacing: 0) { return VStack(spacing: 0) {
if let last { if let last {
DateSeparator(date: last.meta.itemTs).padding(8) DateSeparator(date: last.meta.itemTs).padding(8)
@ -1265,10 +1286,10 @@ struct ChatView: View {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf { if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
let checked = selected.contains(chatItem.id) let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem) selectUnselectChatItem(select: !checked, chatItem)
} })
} }
} }
if let date = itemSeparation.date { if let date = itemSeparation.date {
@ -1317,6 +1338,9 @@ struct ChatView: View {
var unreadMentions: Int = 0 var unreadMentions: Int = 0
for i in range { for i in range {
if i < 0 || i >= im.reversedChatItems.count {
break
}
let ci = im.reversedChatItems[i] let ci = im.reversedChatItems[i]
if ci.isRcvNew { if ci.isRcvNew {
unreadItems.append(ci.id) unreadItems.append(ci.id)
@ -1454,7 +1478,7 @@ struct ChatView: View {
} }
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 10) {
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background) MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
if let mem = m.getGroupMember(member.groupMemberId) { if let mem = m.getGroupMember(member.groupMemberId) {
selectedMember = mem selectedMember = mem
} else { } else {
@ -1463,7 +1487,7 @@ struct ChatView: View {
m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1 m.groupMembersIndexes[member.groupMemberId] = m.groupMembers.count - 1
selectedMember = mem selectedMember = mem
} }
} })
chatItemWithMenu(ci, range, maxWidth, itemSeparation) chatItemWithMenu(ci, range, maxWidth, itemSeparation)
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
} }
@ -1513,9 +1537,9 @@ struct ChatView: View {
} }
} }
@ViewBuilder func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View { func chatItemWithMenu(_ ci: ChatItem, _ range: ClosedRange<Int>?, _ maxWidth: CGFloat, _ itemSeparation: ItemSeparation) -> some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
VStack(alignment: alignment.horizontal, spacing: 3) { return VStack(alignment: alignment.horizontal, spacing: 3) {
HStack { HStack {
if ci.chatDir.sent { if ci.chatDir.sent {
goToItemButton(true) goToItemButton(true)
@ -1607,9 +1631,9 @@ struct ChatView: View {
.padding(.horizontal, 6) .padding(.horizontal, 6)
.padding(.vertical, 4) .padding(.vertical, 4)
.if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in .if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in
v.onTapGesture { v.simultaneousGesture(TapGesture().onEnded {
setReaction(ci, add: !r.userReacted, reaction: r.reaction) setReaction(ci, add: !r.userReacted, reaction: r.reaction)
} })
} }
switch chat.chatInfo { switch chat.chatInfo {
case let .group(groupInfo): case let .group(groupInfo):
@ -2199,15 +2223,12 @@ struct ChatView: View {
} }
func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View { func goToItemInnerButton(_ alignStart: Bool, _ image: String, touchInProgress: Bool, _ onClick: @escaping () -> Void) -> some View {
Button {
onClick()
} label: {
Image(systemName: image) Image(systemName: image)
.resizable() .resizable()
.frame(width: 13, height: 13) .frame(width: 13, height: 13)
.padding([alignStart ? .trailing : .leading], 10) .padding([alignStart ? .trailing : .leading], 10)
.tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4)) .tint(theme.colors.secondary.opacity(touchInProgress ? 1.0 : 0.4))
} .simultaneousGesture(TapGesture().onEnded(onClick))
} }
@ViewBuilder @ViewBuilder
@ -2368,7 +2389,7 @@ struct ReactionContextMenu: View {
@ViewBuilder private func groupMemberReactionList() -> some View { @ViewBuilder private func groupMemberReactionList() -> some View {
if memberReactions.isEmpty { if memberReactions.isEmpty {
ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in
Text(verbatim: " ") textSpace
} }
} else { } else {
ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in

View file

@ -18,7 +18,7 @@ struct ComposeLinkView: View {
var body: some View { var body: some View {
HStack(alignment: .center, spacing: 8) { HStack(alignment: .center, spacing: 8) {
if let linkPreview = linkPreview { if let linkPreview {
linkPreviewView(linkPreview) linkPreviewView(linkPreview)
} else { } else {
ProgressView() ProgressView()

View file

@ -327,6 +327,7 @@ struct ComposeView: View {
@Binding var keyboardVisible: Bool @Binding var keyboardVisible: Bool
@Binding var keyboardHiddenDate: Date @Binding var keyboardHiddenDate: Date
@Binding var selectedRange: NSRange @Binding var selectedRange: NSRange
var disabledText: LocalizedStringKey? = nil
@State var linkUrl: URL? = nil @State var linkUrl: URL? = nil
@State var hasSimplexLink: Bool = false @State var hasSimplexLink: Bool = false
@ -391,7 +392,7 @@ struct ComposeView: View {
Image(systemName: "paperclip") Image(systemName: "paperclip")
.resizable() .resizable()
} }
.disabled(composeState.attachmentDisabled || !chat.userCanSend || (chat.chatInfo.contact?.nextSendGrpInv ?? false)) .disabled(composeState.attachmentDisabled || !chat.chatInfo.sendMsgEnabled || (chat.chatInfo.contact?.nextSendGrpInv ?? false))
.frame(width: 25, height: 25) .frame(width: 25, height: 25)
.padding(.bottom, 16) .padding(.bottom, 16)
.padding(.leading, 12) .padding(.leading, 12)
@ -441,19 +442,13 @@ struct ComposeView: View {
: theme.colors.primary : theme.colors.primary
) )
.padding(.trailing, 12) .padding(.trailing, 12)
.disabled(!chat.userCanSend) .disabled(!chat.chatInfo.sendMsgEnabled)
if chat.userIsObserver { if let disabledText {
Text("you are observer") Text(disabledText)
.italic() .italic()
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.padding(.horizontal, 12) .padding(.horizontal, 12)
.onTapGesture {
AlertManager.shared.showAlertMsg(
title: "You can't send messages!",
message: "Please contact group admin."
)
}
} }
} }
} }
@ -479,8 +474,8 @@ struct ComposeView: View {
hasSimplexLink = false hasSimplexLink = false
} }
} }
.onChange(of: chat.userCanSend) { canSend in .onChange(of: chat.chatInfo.sendMsgEnabled) { sendEnabled in
if !canSend { if !sendEnabled {
cancelCurrentVoiceRecording() cancelCurrentVoiceRecording()
clearCurrentDraft() clearCurrentDraft()
clearState() clearState()
@ -1254,12 +1249,15 @@ struct ComposeView: View {
if pendingLinkUrl == url { if pendingLinkUrl == url {
composeState = composeState.copy(preview: .linkPreview(linkPreview: nil)) composeState = composeState.copy(preview: .linkPreview(linkPreview: nil))
getLinkPreview(url: url) { linkPreview in getLinkPreview(url: url) { linkPreview in
if let linkPreview = linkPreview, if let linkPreview, pendingLinkUrl == url {
pendingLinkUrl == url {
composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview)) composeState = composeState.copy(preview: .linkPreview(linkPreview: linkPreview))
pendingLinkUrl = nil } else {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
composeState = composeState.copy(preview: .noPreview)
} }
} }
pendingLinkUrl = nil
}
} }
} }

View file

@ -70,8 +70,10 @@ struct ContextItemView: View {
.lineLimit(lines) .lineLimit(lines)
} }
private func contextMsgPreview(_ contextItem: ChatItem) -> Text { private func contextMsgPreview(_ contextItem: ChatItem) -> some View {
return attachment() + messageText(contextItem.text, contextItem.formattedText, nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) let r = messageText(contextItem.text, contextItem.formattedText, sender: nil, preview: true, mentions: contextItem.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(background))
let t = attachment() + Text(AttributedString(r.string))
return t.if(r.hasSecrets, transform: hiddenSecretsView)
func attachment() -> Text { func attachment() -> Text {
let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) { let isFileLoaded = if let fileSource = getLoadedFileSource(contextItem.file) {

View file

@ -15,6 +15,7 @@ struct SendMessageView: View {
@Binding var composeState: ComposeState @Binding var composeState: ComposeState
@Binding var selectedRange: NSRange @Binding var selectedRange: NSRange
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.isEnabled) var isEnabled
var sendMessage: (Int?) -> Void var sendMessage: (Int?) -> Void
var sendLiveMessage: (() async -> Void)? = nil var sendLiveMessage: (() async -> Void)? = nil
var updateLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil
@ -255,6 +256,7 @@ struct SendMessageView: View {
} }
private struct RecordVoiceMessageButton: View { private struct RecordVoiceMessageButton: View {
@Environment(\.isEnabled) var isEnabled
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var startVoiceMessageRecording: (() -> Void)? var startVoiceMessageRecording: (() -> Void)?
var finishVoiceMessageRecording: (() -> Void)? var finishVoiceMessageRecording: (() -> Void)?
@ -263,13 +265,12 @@ struct SendMessageView: View {
@State private var pressed: TimeInterval? = nil @State private var pressed: TimeInterval? = nil
var body: some View { var body: some View {
Button(action: {}) { Image(systemName: isEnabled ? "mic.fill" : "mic")
Image(systemName: "mic.fill")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary) .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
} .opacity(holdingVMR ? 0.7 : 1)
.disabled(disabled) .disabled(disabled)
.frame(width: 31, height: 31) .frame(width: 31, height: 31)
.padding([.bottom, .trailing], 4) .padding([.bottom, .trailing], 4)
@ -279,9 +280,7 @@ struct SendMessageView: View {
pressed = ProcessInfo.processInfo.systemUptime pressed = ProcessInfo.processInfo.systemUptime
startVoiceMessageRecording?() startVoiceMessageRecording?()
} else { } else {
let now = ProcessInfo.processInfo.systemUptime if let pressed, ProcessInfo.processInfo.systemUptime - pressed >= 1 {
if let pressed = pressed,
now - pressed >= 1 {
finishVoiceMessageRecording?() finishVoiceMessageRecording?()
} }
holdingVMR = false holdingVMR = false
@ -355,7 +354,7 @@ struct SendMessageView: View {
Image(systemName: "bolt.fill") Image(systemName: "bolt.fill")
.resizable() .resizable()
.scaledToFit() .scaledToFit()
.foregroundColor(theme.colors.primary) .foregroundColor(isEnabled ? theme.colors.primary : theme.colors.secondary)
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
} }
.frame(width: 29, height: 29) .frame(width: 29, height: 29)

View file

@ -171,6 +171,9 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
visibleItems.last?.index ?? 0 visibleItems.last?.index ?? 0
} }
/// Specifies if visible items cover the whole screen or can cover it (if overscrolled)
var itemsCanCoverScreen: Bool = false
/// Whether there is a non-animated scroll to item in progress or not /// Whether there is a non-animated scroll to item in progress or not
var isScrolling: Bool = false var isScrolling: Bool = false
/// Whether there is an animated scroll to item in progress or not /// Whether there is an animated scroll to item in progress or not
@ -284,7 +287,8 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) { func updateItems(_ items: [ScrollItem], _ forceReloadVisible: Bool = false) {
if !Thread.isMainThread { if !Thread.isMainThread {
fatalError("Use main thread to update items") logger.error("Use main thread to update items")
return
} }
if bounds.height == 0 { if bounds.height == 0 {
self.listState.items = items self.listState.items = items
@ -302,6 +306,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
if items.isEmpty { if items.isEmpty {
listState.visibleItems.forEach { item in item.view.removeFromSuperview() } listState.visibleItems.forEach { item in item.view.removeFromSuperview() }
listState.visibleItems = [] listState.visibleItems = []
listState.itemsCanCoverScreen = false
listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID listState.firstVisibleItemId = EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
listState.firstVisibleItemIndex = 0 listState.firstVisibleItemIndex = 0
listState.firstVisibleItemOffset = -insetTop listState.firstVisibleItemOffset = -insetTop
@ -322,6 +327,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
var oldVisible = listState.visibleItems var oldVisible = listState.visibleItems
var newVisible: [VisibleItem] = [] var newVisible: [VisibleItem] = []
var visibleItemsHeight: CGFloat = 0
let offsetsDiff = contentOffsetY - prevProcessedOffset let offsetsDiff = contentOffsetY - prevProcessedOffset
var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0 var shouldBeFirstVisible = items.firstIndex(where: { item in item.id == listState.firstVisibleItemId as! ScrollItem.ID }) ?? 0
@ -339,7 +345,11 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
if let visibleIndex { if let visibleIndex {
let v = oldVisible.remove(at: visibleIndex) let v = oldVisible.remove(at: visibleIndex)
if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue { if forceReloadVisible || v.view.bounds.width != bounds.width || v.item.hashValue != item.hashValue {
let wasHeight = v.view.bounds.height
updateCell(v.view, i, items) updateCell(v.view, i, items)
if wasHeight < v.view.bounds.height && i == 0 && shouldBeFirstVisible == i {
v.view.frame.origin.y -= v.view.bounds.height - wasHeight
}
} }
visible = v visible = v
} else { } else {
@ -389,6 +399,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
addSubview(vis.view) addSubview(vis.view)
} }
newVisible.append(vis) newVisible.append(vis)
visibleItemsHeight += vis.view.frame.height
nextOffsetY = vis.view.frame.origin.y nextOffsetY = vis.view.frame.origin.y
} else { } else {
let vis: VisibleItem let vis: VisibleItem
@ -406,6 +417,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
addSubview(vis.view) addSubview(vis.view)
} }
newVisible.append(vis) newVisible.append(vis)
visibleItemsHeight += vis.view.frame.height
} }
if abs(nextOffsetY) < contentOffsetY && !allowOneMore { if abs(nextOffsetY) < contentOffsetY && !allowOneMore {
break break
@ -435,6 +447,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
} }
offset += vis.view.frame.height offset += vis.view.frame.height
newVisible.insert(vis, at: 0) newVisible.insert(vis, at: 0)
visibleItemsHeight += vis.view.frame.height
if offset >= contentOffsetY + bounds.height { if offset >= contentOffsetY + bounds.height {
break break
} }
@ -450,11 +463,15 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
prevProcessedOffset = contentOffsetY prevProcessedOffset = contentOffsetY
listState.visibleItems = newVisible listState.visibleItems = newVisible
listState.items = items // bottom drawing starts from 0 until top visible area at least (bound.height - insetTop) or above top bar (bounds.height).
// For visible items to preserve offset after adding more items having such height is enough
listState.itemsCanCoverScreen = visibleItemsHeight >= bounds.height - insetTop
listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID listState.firstVisibleItemId = listState.visibleItems.first?.item.id ?? EndlessScrollView<ScrollItem>.DEFAULT_ITEM_ID
listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0 listState.firstVisibleItemIndex = listState.visibleItems.first?.index ?? 0
listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop listState.firstVisibleItemOffset = listState.visibleItems.first?.offset ?? -insetTop
// updating the items with the last step in order to call listener with fully updated state
listState.items = items
estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged) estimatedContentHeight.update(contentOffset, listState, averageItemHeight, itemsCountChanged)
scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight) scrollBarView.contentSize = CGSizeMake(bounds.width, estimatedContentHeight.virtualOverscrolledHeight)
@ -517,7 +534,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
let y = if top { let y = if top {
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
} else { } else {
max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
} }
setContentOffset(CGPointMake(contentOffset.x, y), animated: false) setContentOffset(CGPointMake(contentOffset.x, y), animated: false)
scrollBarView.flashScrollIndicators() scrollBarView.flashScrollIndicators()
@ -568,7 +585,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
let y = if top { let y = if top {
min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop) min(estimatedContentHeight.bottomOffsetY - bounds.height, item.view.frame.origin.y - insetTop)
} else { } else {
max(estimatedContentHeight.topOffsetY - insetTop, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom) max(estimatedContentHeight.topOffsetY - insetTop - insetBottom, item.view.frame.origin.y + item.view.bounds.height - bounds.height + insetBottom)
} }
setContentOffset(CGPointMake(contentOffset.x, y), animated: true) setContentOffset(CGPointMake(contentOffset.x, y), animated: true)
scrollBarView.flashScrollIndicators() scrollBarView.flashScrollIndicators()
@ -632,7 +649,7 @@ class EndlessScrollView<ScrollItem>: UIScrollView, UIScrollViewDelegate, UIGestu
} }
(view as? ReusableView)?.prepareForReuse() (view as? ReusableView)?.prepareForReuse()
view.isHidden = true view.isHidden = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
if view.isHidden { view.removeFromSuperview() } if view.isHidden { view.removeFromSuperview() }
} }
} }

View file

@ -145,9 +145,9 @@ struct AddGroupMembersViewCommon: View {
return dummy return dummy
}() }()
@ViewBuilder private func inviteMembersButton() -> some View { private func inviteMembersButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat" let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Invite to group" : "Invite to chat"
Button { return Button {
inviteMembers() inviteMembers()
} label: { } label: {
HStack { HStack {

View file

@ -21,7 +21,7 @@ struct GroupChatInfoView: View {
@State var localAlias: String @State var localAlias: String
@FocusState private var aliasTextFieldFocused: Bool @FocusState private var aliasTextFieldFocused: Bool
@State private var alert: GroupChatInfoViewAlert? = nil @State private var alert: GroupChatInfoViewAlert? = nil
@State private var groupLink: String? @State private var groupLink: CreatedConnLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var groupLinkNavLinkActive: Bool = false @State private var groupLinkNavLinkActive: Bool = false
@State private var addMembersNavLinkActive: Bool = false @State private var addMembersNavLinkActive: Bool = false
@ -144,17 +144,9 @@ struct GroupChatInfoView: View {
let filteredMembers = s == "" let filteredMembers = s == ""
? members ? members
: members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) } : members.filter { $0.wrapped.localAliasAndFullName.localizedLowercase.contains(s) }
MemberRowView(groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert) MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: GMember(groupInfo.membership), user: true, alert: $alert)
ForEach(filteredMembers) { member in ForEach(filteredMembers) { member in
ZStack { MemberRowView(chat: chat, groupInfo: groupInfo, groupMember: member, alert: $alert)
NavigationLink {
memberInfoView(member)
} label: {
EmptyView()
}
.opacity(0)
MemberRowView(groupInfo: groupInfo, groupMember: member, alert: $alert)
}
} }
} }
@ -292,9 +284,9 @@ struct GroupChatInfoView: View {
.disabled(!groupInfo.ready || chat.chatItems.isEmpty) .disabled(!groupInfo.ready || chat.chatItems.isEmpty)
} }
@ViewBuilder private func addMembersActionButton(width: CGFloat) -> some View { private func addMembersActionButton(width: CGFloat) -> some View {
if chat.chatInfo.incognito {
ZStack { ZStack {
if chat.chatInfo.incognito {
InfoViewButton(image: "link.badge.plus", title: "invite", width: width) { InfoViewButton(image: "link.badge.plus", title: "invite", width: width) {
groupLinkNavLinkActive = true groupLinkNavLinkActive = true
} }
@ -306,10 +298,7 @@ struct GroupChatInfoView: View {
} }
.frame(width: 1, height: 1) .frame(width: 1, height: 1)
.hidden() .hidden()
}
.disabled(!groupInfo.ready)
} else { } else {
ZStack {
InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) { InfoViewButton(image: "person.fill.badge.plus", title: "invite", width: width) {
addMembersNavLinkActive = true addMembersNavLinkActive = true
} }
@ -322,8 +311,8 @@ struct GroupChatInfoView: View {
.frame(width: 1, height: 1) .frame(width: 1, height: 1)
.hidden() .hidden()
} }
.disabled(!groupInfo.ready)
} }
.disabled(!groupInfo.ready)
} }
private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View { private func muteButton(width: CGFloat, nextNtfMode: MsgFilter) -> some View {
@ -361,6 +350,7 @@ struct GroupChatInfoView: View {
} }
private struct MemberRowView: View { private struct MemberRowView: View {
var chat: Chat
var groupInfo: GroupInfo var groupInfo: GroupInfo
@ObservedObject var groupMember: GMember @ObservedObject var groupMember: GMember
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@ -369,7 +359,7 @@ struct GroupChatInfoView: View {
var body: some View { var body: some View {
let member = groupMember.wrapped let member = groupMember.wrapped
let v = HStack{ let v1 = HStack{
MemberProfileImage(member, size: 38) MemberProfileImage(member, size: 38)
.padding(.trailing, 2) .padding(.trailing, 2)
// TODO server connection status // TODO server connection status
@ -386,6 +376,20 @@ struct GroupChatInfoView: View {
memberInfo(member) memberInfo(member)
} }
let v = ZStack {
if user {
v1
} else {
NavigationLink {
memberInfoView()
} label: {
EmptyView()
}
.opacity(0)
v1
}
}
if user { if user {
v v
} else if groupInfo.membership.memberRole >= .admin { } else if groupInfo.membership.memberRole >= .admin {
@ -410,6 +414,11 @@ struct GroupChatInfoView: View {
} }
} }
private func memberInfoView() -> some View {
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
.navigationBarHidden(false)
}
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey { private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false { if member.activeConn?.connDisabled ?? false {
return "disabled" return "disabled"
@ -489,11 +498,6 @@ struct GroupChatInfoView: View {
} }
} }
private func memberInfoView(_ groupMember: GMember) -> some View {
GroupMemberInfoView(groupInfo: groupInfo, chat: chat, groupMember: groupMember)
.navigationBarHidden(false)
}
private func groupLinkButton() -> some View { private func groupLinkButton() -> some View {
NavigationLink { NavigationLink {
groupLinkDestinationView() groupLinkDestinationView()
@ -569,9 +573,9 @@ struct GroupChatInfoView: View {
} }
} }
@ViewBuilder private func leaveGroupButton() -> some View { private func leaveGroupButton() -> some View {
let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat" let label: LocalizedStringKey = groupInfo.businessChat == nil ? "Leave group" : "Leave chat"
Button(role: .destructive) { return Button(role: .destructive) {
alert = .leaveGroupAlert alert = .leaveGroupAlert
} label: { } label: {
Label(label, systemImage: "rectangle.portrait.and.arrow.right") Label(label, systemImage: "rectangle.portrait.and.arrow.right")

View file

@ -10,12 +10,14 @@ import SwiftUI
import SimpleXChat import SimpleXChat
struct GroupLinkView: View { struct GroupLinkView: View {
@EnvironmentObject var theme: AppTheme
var groupId: Int64 var groupId: Int64
@Binding var groupLink: String? @Binding var groupLink: CreatedConnLink?
@Binding var groupLinkMemberRole: GroupMemberRole @Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false var showTitle: Bool = false
var creatingGroup: Bool = false var creatingGroup: Bool = false
var linkCreatedCb: (() -> Void)? = nil var linkCreatedCb: (() -> Void)? = nil
@State private var showShortLink = true
@State private var creatingLink = false @State private var creatingLink = false
@State private var alert: GroupLinkAlert? @State private var alert: GroupLinkAlert?
@State private var shouldCreate = true @State private var shouldCreate = true
@ -69,10 +71,10 @@ struct GroupLinkView: View {
} }
} }
.frame(height: 36) .frame(height: 36)
SimpleXLinkQRCode(uri: groupLink) SimpleXCreatedLinkQRCode(link: groupLink, short: $showShortLink)
.id("simplex-qrcode-view-for-\(groupLink)") .id("simplex-qrcode-view-for-\(groupLink.simplexChatUri(short: showShortLink))")
Button { Button {
showShareSheet(items: [simplexChatLink(groupLink)]) showShareSheet(items: [groupLink.simplexChatUri(short: showShortLink)])
} label: { } label: {
Label("Share link", systemImage: "square.and.arrow.up") Label("Share link", systemImage: "square.and.arrow.up")
} }
@ -93,6 +95,10 @@ struct GroupLinkView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
} }
} header: {
if let groupLink, groupLink.connShortLink != nil {
ToggleShortLinkHeader(text: Text(""), link: groupLink, short: $showShortLink)
}
} }
.alert(item: $alert) { alert in .alert(item: $alert) { alert in
switch alert { switch alert {
@ -158,8 +164,8 @@ struct GroupLinkView: View {
struct GroupLinkView_Previews: PreviewProvider { struct GroupLinkView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
@State var groupLink: String? = "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D" @State var groupLink: CreatedConnLink? = CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil)
@State var noGroupLink: String? = nil @State var noGroupLink: CreatedConnLink? = nil
return Group { return Group {
GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member)) GroupLinkView(groupId: 1, groupLink: $groupLink, groupLinkMemberRole: Binding.constant(.member))

View file

@ -392,7 +392,7 @@ struct GroupMemberInfoView: View {
await MainActor.run { await MainActor.run {
progressIndicator = false progressIndicator = false
chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact))) chatModel.addChat(Chat(chatInfo: .direct(contact: memberContact)))
ItemsModel.shared.loadOpenChat("@\(memberContact.id)") { ItemsModel.shared.loadOpenChat(memberContact.id) {
dismissAllSheets(animated: true) dismissAllSheets(animated: true)
} }
NetworkModel.shared.setContactNetworkStatus(memberContact, .connected) NetworkModel.shared.setContactNetworkStatus(memberContact, .connected)

View file

@ -196,7 +196,9 @@ struct GroupMentionsView: View {
newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName) newName = composeState.mentionMemberName(member.wrapped.memberProfile.displayName)
} }
mentions[newName] = CIMention(groupMember: member.wrapped) mentions[newName] = CIMention(groupMember: member.wrapped)
var msgMention = "@" + (newName.contains(" ") ? "'\(newName)'" : newName) var msgMention = newName.contains(" ") || newName.last?.isPunctuation == true
? "@'\(newName)'"
: "@\(newName)"
var newPos = r.location + msgMention.count var newPos = r.location + msgMention.count
let newMsgLength = composeState.message.count + msgMention.count - r.length let newMsgLength = composeState.message.count + msgMention.count - r.length
print(newPos) print(newPos)

View file

@ -18,6 +18,7 @@ struct GroupWelcomeView: View {
@State private var editMode = true @State private var editMode = true
@FocusState private var keyboardVisible: Bool @FocusState private var keyboardVisible: Bool
@State private var showSaveDialog = false @State private var showSaveDialog = false
@State private var showSecrets: Set<Int> = []
let maxByteCount = 1200 let maxByteCount = 1200
@ -58,7 +59,8 @@ struct GroupWelcomeView: View {
} }
private func textPreview() -> some View { private func textPreview() -> some View {
messageText(welcomeText, parseSimpleXMarkdown(welcomeText), nil, mentions: nil, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) let r = messageText(welcomeText, parseSimpleXMarkdown(welcomeText), sender: nil, mentions: nil, userMemberId: nil, showSecrets: showSecrets, backgroundColor: UIColor(theme.colors.background))
return msgTextResultView(r, Text(AttributedString(r.string)), showSecrets: $showSecrets)
.frame(minHeight: 130, alignment: .topLeading) .frame(minHeight: 130, alignment: .topLeading)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
} }

View file

@ -90,11 +90,11 @@ struct ChatListNavLink: View {
.actionSheet(item: $actionSheet) { $0.actionSheet } .actionSheet(item: $actionSheet) { $0.actionSheet }
} }
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View { private func contactNavLink(_ contact: Contact) -> some View {
Group { Group {
if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active { if contact.activeConn == nil && contact.profile.contactLink != nil && contact.active {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight) .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
deleteContactDialog( deleteContactDialog(
@ -121,6 +121,7 @@ struct ChatListNavLink: View {
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) } label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
) )
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
@ -145,7 +146,6 @@ struct ChatListNavLink: View {
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight)
} }
} }
.alert(item: $alert) { $0.alert } .alert(item: $alert) { $0.alert }
@ -163,7 +163,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) { switch (groupInfo.membership.memberStatus) {
case .memInvited: case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout) ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: dynamicRowHeight) .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton() joinGroupButton()
if groupInfo.canDelete { if groupInfo.canDelete {
@ -183,7 +183,7 @@ struct ChatListNavLink: View {
.disabled(inProgress) .disabled(inProgress)
case .memAccepted: case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: dynamicRowHeight) .frameCompat(height: dynamicRowHeight)
.onTapGesture { .onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert()) AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
} }
@ -203,7 +203,7 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready disabled: !groupInfo.ready
) )
.frame(height: dynamicRowHeight) .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
toggleFavoriteButton() toggleFavoriteButton()
@ -243,14 +243,14 @@ struct ChatListNavLink: View {
} }
} }
@ViewBuilder private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View { private func noteFolderNavLink(_ noteFolder: NoteFolder) -> some View {
NavLinkPlain( NavLinkPlain(
chatId: chat.chatInfo.id, chatId: chat.chatInfo.id,
selection: $chatModel.chatId, selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }, label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready disabled: !noteFolder.ready
) )
.frame(height: dynamicRowHeight) .frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton() markReadButton()
} }
@ -433,6 +433,7 @@ struct ChatListNavLink: View {
private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View { private func contactRequestNavLink(_ contactRequest: UserContactRequest) -> some View {
ContactRequestView(contactRequest: contactRequest, chat: chat) ContactRequestView(contactRequest: contactRequest, chat: chat)
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) }
@ -451,7 +452,6 @@ struct ChatListNavLink: View {
} }
.tint(.red) .tint(.red)
} }
.frame(height: dynamicRowHeight)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { showContactRequestDialog = true } .onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) { .confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
@ -463,6 +463,7 @@ struct ChatListNavLink: View {
private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View { private func contactConnectionNavLink(_ contactConnection: PendingContactConnection) -> some View {
ContactConnectionView(chat: chat) ContactConnectionView(chat: chat)
.frameCompat(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button { Button {
AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in AlertManager.shared.showAlert(deleteContactConnectionAlert(contactConnection) { a in
@ -480,7 +481,6 @@ struct ChatListNavLink: View {
} }
.tint(theme.colors.primary) .tint(theme.colors.primary)
} }
.frame(height: dynamicRowHeight)
.appSheet(isPresented: $showContactConnectionInfo) { .appSheet(isPresented: $showContactConnectionInfo) {
Group { Group {
if case let .contactConnection(contactConnection) = chat.chatInfo { if case let .contactConnection(contactConnection) = chat.chatInfo {
@ -579,14 +579,14 @@ struct ChatListNavLink: View {
) )
} }
private func invalidJSONPreview(_ json: String) -> some View { private func invalidJSONPreview(_ json: Data?) -> some View {
Text("invalid chat data") Text("invalid chat data")
.foregroundColor(.red) .foregroundColor(.red)
.padding(4) .padding(4)
.frame(height: dynamicRowHeight) .frameCompat(height: dynamicRowHeight)
.onTapGesture { showInvalidJSON = true } .onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) { .appSheet(isPresented: $showInvalidJSON) {
invalidJSONView(json) invalidJSONView(dataToString(json))
.environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil) .environment(\EnvironmentValues.refresh as! WritableKeyPath<EnvironmentValues, RefreshAction?>, nil)
} }
} }
@ -595,12 +595,31 @@ struct ChatListNavLink: View {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) }) let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { AlertManager.shared.showAlert($0) })
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) ItemsModel.shared.loadOpenChat(contact.id) {
AlertManager.shared.showAlert(connReqSentAlert(.contact)) AlertManager.shared.showAlert(connReqSentAlert(.contact))
} }
} }
} }
} }
}
extension View {
@inline(__always)
@ViewBuilder fileprivate func frameCompat(height: CGFloat) -> some View {
if #available(iOS 16, *) {
self.frame(height: height)
} else {
VStack(spacing: 0) {
Divider()
.padding(.leading, 16)
self
.frame(height: height)
.padding(.horizontal, 8)
.padding(.vertical, 8)
}
}
}
}
func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert { func rejectContactRequestAlert(_ contactRequest: UserContactRequest) -> Alert {
Alert( Alert(
@ -688,7 +707,7 @@ func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
} }
func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert { func getErrorAlert(_ error: Error, _ title: LocalizedStringKey) -> ErrorAlert {
if let r = error as? ChatResponse, if let r = error as? ChatError,
let alert = getNetworkErrorAlert(r) { let alert = getNetworkErrorAlert(r) {
return alert return alert
} else { } else {

View file

@ -149,6 +149,9 @@ struct ChatListView: View {
@State private var sheet: SomeSheet<AnyView>? = nil @State private var sheet: SomeSheet<AnyView>? = nil
@StateObject private var chatTagsModel = ChatTagsModel.shared @StateObject private var chatTagsModel = ChatTagsModel.shared
// iOS 15 is required it to show/hide toolbar while chat is hidden/visible
@State private var viewOnScreen = true
@AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true @AppStorage(GROUP_DEFAULT_ONE_HAND_UI, store: groupDefaults) private var oneHandUI = true
@AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false @AppStorage(DEFAULT_ONE_HAND_UI_CARD_SHOWN) private var oneHandUICardShown = false
@AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false @AppStorage(DEFAULT_ADDRESS_CREATION_CARD_SHOWN) private var addressCreationCardShown = false
@ -203,7 +206,17 @@ struct ChatListView: View {
.navigationBarHidden(searchMode || oneHandUI) .navigationBarHidden(searchMode || oneHandUI)
} }
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.onDisappear() { activeUserPickerSheet = nil } .onAppear {
if #unavailable(iOS 16.0), !viewOnScreen {
viewOnScreen = true
}
}
.onDisappear {
activeUserPickerSheet = nil
if #unavailable(iOS 16.0) {
viewOnScreen = false
}
}
.refreshable { .refreshable {
AlertManager.shared.showAlert(Alert( AlertManager.shared.showAlert(Alert(
title: Text("Reconnect servers?"), title: Text("Reconnect servers?"),
@ -258,7 +271,7 @@ struct ChatListView: View {
} }
} else { } else {
if oneHandUI { if oneHandUI {
content().toolbar { bottomToolbarGroup } content().toolbar { bottomToolbarGroup() }
} else { } else {
content().toolbar { topToolbar } content().toolbar { topToolbar }
} }
@ -286,9 +299,9 @@ struct ChatListView: View {
} }
} }
@ToolbarContentBuilder var bottomToolbarGroup: some ToolbarContent { @ToolbarContentBuilder func bottomToolbarGroup() -> some ToolbarContent {
let padding: Double = Self.hasHomeIndicator ? 0 : 14 let padding: Double = Self.hasHomeIndicator ? 0 : 14
ToolbarItemGroup(placement: .bottomBar) { ToolbarItemGroup(placement: viewOnScreen ? .bottomBar : .principal) {
leadingToolbarItem.padding(.bottom, padding) leadingToolbarItem.padding(.bottom, padding)
Spacer() Spacer()
SubsStatusIndicator().padding(.bottom, padding) SubsStatusIndicator().padding(.bottom, padding)
@ -322,9 +335,9 @@ struct ChatListView: View {
} }
} }
@ViewBuilder private var chatList: some View { private var chatList: some View {
let cs = filteredChats() let cs = filteredChats()
ZStack { return ZStack {
ScrollViewReader { scrollProxy in ScrollViewReader { scrollProxy in
List { List {
if !chatModel.chats.isEmpty { if !chatModel.chats.isEmpty {
@ -354,13 +367,7 @@ struct ChatListView: View {
.offset(x: -8) .offset(x: -8)
} else { } else {
ForEach(cs, id: \.viewId) { chat in ForEach(cs, id: \.viewId) { chat in
VStack(spacing: .zero) {
Divider()
.padding(.leading, 16)
ChatListNavLink(chat: chat, parentSheet: $sheet) ChatListNavLink(chat: chat, parentSheet: $sheet)
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center) .scaleEffect(x: 1, y: oneHandUI ? -1 : 1, anchor: .center)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
@ -791,7 +798,7 @@ struct TagsView: View {
} }
} }
@ViewBuilder private func expandedPresetTagsFiltersView() -> some View { private func expandedPresetTagsFiltersView() -> some View {
ForEach(PresetTag.allCases, id: \.id) { tag in ForEach(PresetTag.allCases, id: \.id) { tag in
if (chatTagsModel.presetTags[tag] ?? 0) > 0 { if (chatTagsModel.presetTags[tag] ?? 0) > 0 {
expandedTagFilterView(tag) expandedTagFilterView(tag)

View file

@ -187,13 +187,14 @@ struct ChatPreviewView: View {
.kerning(-2) .kerning(-2)
} }
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View { private func chatPreviewLayout(_ text: Text?, draft: Bool = false, hasFilePreview: Bool = false, hasSecrets: Bool) -> some View {
ZStack(alignment: .topTrailing) { ZStack(alignment: .topTrailing) {
let s = chat.chatStats let s = chat.chatStats
let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 } let mentionWidth: CGFloat = if s.unreadMentions > 0 && s.unreadCount > 1 { dynamicSize(userFont).unreadCorner } else { 0 }
let t = text let t = text
.lineLimit(userFont <= .xxxLarge ? 2 : 1) .lineLimit(userFont <= .xxxLarge ? 2 : 1)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.if(hasSecrets, transform: hiddenSecretsView)
.frame(maxWidth: .infinity, alignment: .topLeading) .frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, hasFilePreview ? 0 : 8) .padding(.leading, hasFilePreview ? 0 : 8)
.padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36)) .padding(.trailing, mentionWidth + (hasFilePreview ? 38 : 36))
@ -259,11 +260,13 @@ struct ChatPreviewView: View {
} }
} }
private func messageDraft(_ draft: ComposeState) -> Text { private func messageDraft(_ draft: ComposeState) -> (Text, Bool) {
let msg = draft.message let msg = draft.message
return image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary) let r = messageText(msg, parseSimpleXMarkdown(msg), sender: nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: nil, backgroundColor: UIColor(theme.colors.background))
return (image("rectangle.and.pencil.and.ellipsis", color: theme.colors.primary)
+ attachment() + attachment()
+ messageText(msg, parseSimpleXMarkdown(msg), nil, preview: true, mentions: draft.mentions, userMemberId: nil, showSecrets: false, secondaryColor: theme.colors.secondary) + Text(AttributedString(r.string)),
r.hasSecrets)
func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text { func image(_ s: String, color: Color = Color(uiColor: .tertiaryLabel)) -> Text {
Text(Image(systemName: s)).foregroundColor(color) + textSpace Text(Image(systemName: s)).foregroundColor(color) + textSpace
@ -279,10 +282,11 @@ struct ChatPreviewView: View {
} }
} }
func chatItemPreview(_ cItem: ChatItem) -> Text { func chatItemPreview(_ cItem: ChatItem) -> (Text, Bool) {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText() let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: false, secondaryColor: theme.colors.secondary, prefix: prefix()) let r = messageText(itemText, itemFormattedText, sender: cItem.memberDisplayName, preview: true, mentions: cItem.mentions, userMemberId: chat.chatInfo.groupInfo?.membership.memberId, showSecrets: nil, backgroundColor: UIColor(theme.colors.background), prefix: prefix())
return (Text(AttributedString(r.string)), r.hasSecrets)
// same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey; // same texts are in markedDeletedText in MarkedDeletedItemView, but it returns LocalizedStringKey;
// can be refactored into a single function if functions calling these are changed to return same type // can be refactored into a single function if functions calling these are changed to return same type
@ -309,19 +313,21 @@ struct ChatPreviewView: View {
} }
} }
func prefix() -> Text { func prefix() -> NSAttributedString? {
switch cItem.content.msgContent { switch cItem.content.msgContent {
case let .report(_, reason): return Text(!itemText.isEmpty ? "\(reason.text): " : reason.text).italic().foregroundColor(Color.red) case let .report(_, reason): reason.attrString
default: return Text("") default: nil
} }
} }
} }
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft { if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview) let (t, hasSecrets) = messageDraft(draft)
chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
} else if let cItem = cItem { } else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview) let (t, hasSecrets) = chatItemPreview(cItem)
chatPreviewLayout(itemStatusMark(cItem) + t, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets)
} else { } else {
switch (chat.chatInfo) { switch (chat.chatInfo) {
case let .direct(contact): case let .direct(contact):
@ -348,7 +354,6 @@ struct ChatPreviewView: View {
} }
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View { @ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let linkClicksEnabled = privacyChatListOpenLinksDefault.get() != PrivacyChatListOpenLinksMode.no
let mc = ci.content.msgContent let mc = ci.content.msgContent
switch mc { switch mc {
case let .link(_, preview): case let .link(_, preview):
@ -370,17 +375,7 @@ struct ChatPreviewView: View {
.cornerRadius(8) .cornerRadius(8)
} }
.onTapGesture { .onTapGesture {
switch privacyChatListOpenLinksDefault.get() { openBrowserAlert(uri: preview.uri)
case .yes: UIApplication.shared.open(preview.uri)
case .no: ItemsModel.shared.loadOpenChat(chat.id)
case .ask: AlertManager.shared.showAlert(
Alert(title: Text("Open web link?"),
message: Text(preview.uri.absoluteString),
primaryButton: .default(Text("Open chat"), action: { ItemsModel.shared.loadOpenChat(chat.id) }),
secondaryButton: .default(Text("Open link"), action: { UIApplication.shared.open(preview.uri) })
)
)
}
} }
} }
case let .image(_, image): case let .image(_, image):
@ -410,7 +405,7 @@ struct ChatPreviewView: View {
: chatPreviewInfoText("you are invited to group") : chatPreviewInfoText("you are invited to group")
} }
@ViewBuilder private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View { private func chatPreviewInfoText(_ text: LocalizedStringKey) -> some View {
Text(text) Text(text)
.frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading) .frame(maxWidth: .infinity, minHeight: 44, maxHeight: 44, alignment: .topLeading)
.padding([.leading, .trailing], 8) .padding([.leading, .trailing], 8)
@ -490,7 +485,7 @@ struct ChatPreviewView: View {
} }
} }
@ViewBuilder func groupReportsIcon(size: CGFloat) -> some View { func groupReportsIcon(size: CGFloat) -> some View {
Image(systemName: "flag") Image(systemName: "flag")
.resizable() .resizable()
.scaledToFit() .scaledToFit()

View file

@ -14,6 +14,7 @@ struct ContactConnectionInfo: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@State var contactConnection: PendingContactConnection @State var contactConnection: PendingContactConnection
@State private var showShortLink: Bool = true
@State private var alert: CCInfoAlert? @State private var alert: CCInfoAlert?
@State private var localAlias = "" @State private var localAlias = ""
@State private var showIncognitoSheet = false @State private var showIncognitoSheet = false
@ -61,14 +62,19 @@ struct ContactConnectionInfo: View {
} }
if contactConnection.initiated, if contactConnection.initiated,
let connReqInv = contactConnection.connReqInv { let connLinkInv = contactConnection.connLinkInv {
SimpleXLinkQRCode(uri: simplexChatLink(connReqInv)) SimpleXCreatedLinkQRCode(link: connLinkInv, short: $showShortLink)
.id("simplex-invitation-qrcode-\(connLinkInv.simplexChatUri(short: showShortLink))")
incognitoEnabled() incognitoEnabled()
shareLinkButton(connReqInv, theme.colors.secondary) shareLinkButton(connLinkInv, short: showShortLink)
oneTimeLinkLearnMoreButton(theme.colors.secondary) oneTimeLinkLearnMoreButton()
} else { } else {
incognitoEnabled() incognitoEnabled()
oneTimeLinkLearnMoreButton(theme.colors.secondary) oneTimeLinkLearnMoreButton()
}
} header: {
if let connLinkInv = contactConnection.connLinkInv, connLinkInv.connShortLink != nil {
ToggleShortLinkHeader(text: Text(""), link: connLinkInv, short: $showShortLink)
} }
} footer: { } footer: {
sharedProfileInfo(contactConnection.incognito) sharedProfileInfo(contactConnection.incognito)
@ -167,26 +173,22 @@ struct ContactConnectionInfo: View {
} }
} }
private func shareLinkButton(_ connReqInvitation: String, _ secondaryColor: Color) -> some View { private func shareLinkButton(_ connLinkInvitation: CreatedConnLink, short: Bool) -> some View {
Button { Button {
showShareSheet(items: [simplexChatLink(connReqInvitation)]) showShareSheet(items: [connLinkInvitation.simplexChatUri(short: short)])
} label: { } label: {
settingsRow("square.and.arrow.up", color: secondaryColor) { Label("Share 1-time link", systemImage: "square.and.arrow.up")
Text("Share 1-time link")
}
} }
} }
private func oneTimeLinkLearnMoreButton(_ secondaryColor: Color) -> some View { private func oneTimeLinkLearnMoreButton() -> some View {
NavigationLink { NavigationLink {
AddContactLearnMore(showTitle: false) AddContactLearnMore(showTitle: false)
.navigationTitle("One-time invitation link") .navigationTitle("One-time invitation link")
.modifier(ThemedBackground()) .modifier(ThemedBackground())
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} label: { } label: {
settingsRow("info.circle", color: secondaryColor) { Label("Learn more", systemImage: "info.circle")
Text("Learn more")
}
} }
} }

View file

@ -245,7 +245,7 @@ struct ServersSummaryView: View {
} }
} }
@ViewBuilder private func smpServersListView( private func smpServersListView(
_ servers: [SMPServerSummary], _ servers: [SMPServerSummary],
_ statsStartedAt: Date, _ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil, _ header: LocalizedStringKey? = nil,
@ -256,7 +256,7 @@ struct ServersSummaryView: View {
? serverAddress($0.smpServer) < serverAddress($1.smpServer) ? serverAddress($0.smpServer) < serverAddress($1.smpServer)
: $0.hasSubs && !$1.hasSubs : $0.hasSubs && !$1.hasSubs
} }
Section { return Section {
ForEach(sortedServers) { server in ForEach(sortedServers) { server in
smpServerView(server, statsStartedAt) smpServerView(server, statsStartedAt)
} }
@ -318,14 +318,14 @@ struct ServersSummaryView: View {
return onionHosts == .require ? .indigo : .accentColor return onionHosts == .require ? .indigo : .accentColor
} }
@ViewBuilder private func xftpServersListView( private func xftpServersListView(
_ servers: [XFTPServerSummary], _ servers: [XFTPServerSummary],
_ statsStartedAt: Date, _ statsStartedAt: Date,
_ header: LocalizedStringKey? = nil, _ header: LocalizedStringKey? = nil,
_ footer: LocalizedStringKey? = nil _ footer: LocalizedStringKey? = nil
) -> some View { ) -> some View {
let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) } let sortedServers = servers.sorted { serverAddress($0.xftpServer) < serverAddress($1.xftpServer) }
Section { return Section {
ForEach(sortedServers) { server in ForEach(sortedServers) { server in
xftpServerView(server, statsStartedAt) xftpServerView(server, statsStartedAt)
} }
@ -587,7 +587,7 @@ struct SMPStatsView: View {
} header: { } header: {
Text("Statistics") Text("Statistics")
} footer: { } footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.") Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
} }
} }
} }
@ -703,7 +703,7 @@ struct XFTPStatsView: View {
} header: { } header: {
Text("Statistics") Text("Statistics")
} footer: { } footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).") + Text("\n") + Text("All data is kept private on your device.") Text("Starting from \(localTimestamp(statsStartedAt)).") + textNewLine + Text("All data is kept private on your device.")
} }
} }
} }

View file

@ -61,7 +61,7 @@ struct TagListView: View {
Button { Button {
showAlert( showAlert(
NSLocalizedString("Delete list?", comment: "alert title"), NSLocalizedString("Delete list?", comment: "alert title"),
message: NSLocalizedString("All chats will be removed from the list \(text), and the list deleted.", comment: "alert message"), message: String.localizedStringWithFormat(NSLocalizedString("All chats will be removed from the list %@, and the list deleted.", comment: "alert message"), text),
actions: {[ actions: {[
UIAlertAction( UIAlertAction(
title: NSLocalizedString("Cancel", comment: "alert action"), title: NSLocalizedString("Cancel", comment: "alert action"),
@ -138,7 +138,7 @@ struct TagListView: View {
} }
} }
@ViewBuilder private func radioButton(selected: Bool) -> some View { private func radioButton(selected: Bool) -> some View {
Image(systemName: selected ? "checkmark.circle.fill" : "circle") Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.imageScale(.large) .imageScale(.large)
.foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel)) .foregroundStyle(selected ? Color.accentColor : Color(.tertiaryLabel))

View file

@ -140,9 +140,9 @@ struct ContactListNavLink: View {
} }
} }
@ViewBuilder private func previewTitle(_ contact: Contact, titleColor: Color) -> some View { private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor) let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
( return (
contact.verified == true contact.verified == true
? verifiedIcon + t ? verifiedIcon + t
: t : t
@ -188,8 +188,7 @@ struct ContactListNavLink: View {
Task { Task {
let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") }) let ok = await connectContactViaAddress(contact.contactId, incognito, showAlert: { alert = SomeAlert(alert: $0, id: "ContactListNavLink connectContactViaAddress") })
if ok { if ok {
ItemsModel.shared.loadOpenChat(contact.id) ItemsModel.shared.loadOpenChat(contact.id) {
DispatchQueue.main.async {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
AlertManager.shared.showAlert(connReqSentAlert(.contact)) AlertManager.shared.showAlert(connReqSentAlert(.contact))
} }

View file

@ -173,7 +173,7 @@ struct DatabaseEncryptionView: View {
} }
return true return true
} catch let error { } catch let error {
if case .chatCmdError(_, .errorDatabase(.errorExport(.errorNotADatabase))) = error as? ChatResponse { if case .errorDatabase(.errorExport(.errorNotADatabase)) = error as? ChatError {
await operationEnded(.currentPassphraseError) await operationEnded(.currentPassphraseError)
} else { } else {
await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))")) await operationEnded(.error(title: "Error encrypting database", error: "\(responseError(error))"))

View file

@ -28,7 +28,7 @@ struct DatabaseErrorView: View {
} }
} }
@ViewBuilder private func databaseErrorView() -> some View { private func databaseErrorView() -> some View {
VStack(alignment: .center, spacing: 20) { VStack(alignment: .center, spacing: 20) {
switch status { switch status {
case let .errorNotADatabase(dbFile): case let .errorNotADatabase(dbFile):
@ -141,7 +141,7 @@ struct DatabaseErrorView: View {
} }
private func migrationsText(_ ms: [String]) -> some View { private func migrationsText(_ ms: [String]) -> some View {
(Text("Migrations:").font(.subheadline) + Text(verbatim: "\n") + Text(ms.joined(separator: "\n")).font(.caption)) (Text("Migrations:").font(.subheadline) + textNewLine + Text(ms.joined(separator: "\n")).font(.caption))
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.padding(.horizontal, 25) .padding(.horizontal, 25)
} }

View file

@ -279,7 +279,7 @@ struct DatabaseView: View {
case let .archiveExportedWithErrors(archivePath, errs): case let .archiveExportedWithErrors(archivePath, errs):
return Alert( return Alert(
title: Text("Chat database exported"), title: Text("Chat database exported"),
message: Text("You may save the exported archive.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), message: Text("You may save the exported archive.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) { dismissButton: .default(Text("Continue")) {
showShareSheet(items: [archivePath]) showShareSheet(items: [archivePath])
} }

View file

@ -9,6 +9,7 @@
import SwiftUI import SwiftUI
extension View { extension View {
@inline(__always)
@ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View { @ViewBuilder func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { if condition {
transform(self) transform(self)
@ -36,9 +37,9 @@ struct PrivacyBlur: ViewModifier {
.overlay { .overlay {
if (blurred && enabled) { if (blurred && enabled) {
Color.clear.contentShape(Rectangle()) Color.clear.contentShape(Rectangle())
.onTapGesture { .simultaneousGesture(TapGesture().onEnded {
blurred = false blurred = false
} })
} }
} }
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in .onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in

View file

@ -65,7 +65,7 @@ struct LocalAuthView: View {
// Clear sensitive data on screen just in case app fails to hide its views while new database is created // Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil m.chatId = nil
ItemsModel.shared.reversedChatItems = [] ItemsModel.shared.reversedChatItems = []
ItemsModel.shared.chatItemsChangesListener.cleared() ItemsModel.shared.chatState.clear()
m.updateChats([]) m.updateChats([])
m.users = [] m.users = []
_ = kcAppPassword.set(password) _ = kcAppPassword.set(password)

View file

@ -28,7 +28,7 @@ struct PasscodeEntry: View {
} }
} }
@ViewBuilder private func passwordView() -> some View { private func passwordView() -> some View {
Text( Text(
password == "" password == ""
? " " ? " "

View file

@ -177,7 +177,7 @@ struct MigrateFromDevice: View {
case let .archiveExportedWithErrors(archivePath, errs): case let .archiveExportedWithErrors(archivePath, errs):
return Alert( return Alert(
title: Text("Chat database exported"), title: Text("Chat database exported"),
message: Text("You may migrate the exported database.") + Text(verbatim: "\n") + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)), message: Text("You may migrate the exported database.") + textNewLine + Text("Some file(s) were not exported:") + Text(archiveErrorsText(errs)),
dismissButton: .default(Text("Continue")) { dismissButton: .default(Text("Continue")) {
Task { await uploadArchive(path: archivePath) } Task { await uploadArchive(path: archivePath) }
} }
@ -520,15 +520,15 @@ struct MigrateFromDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run { await MainActor.run {
switch msg { switch msg {
case let .sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize): case let .result(.sndFileProgressXFTP(_, _, fileTransferMeta, sentSize, totalSize)):
if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total { if case let .uploadProgress(uploaded, total, _, _, _) = migrationState, uploaded != total {
migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl) migrationState = .uploadProgress(uploadedBytes: sentSize, totalBytes: totalSize, fileId: fileTransferMeta.fileId, archivePath: archivePath, ctrl: ctrl)
} }
case .sndFileRedirectStartXFTP: case .result(.sndFileRedirectStartXFTP):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkCreation migrationState = .linkCreation
} }
case let .sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs): case let .result(.sndStandaloneFileComplete(_, fileTransferMeta, rcvURIs)):
let cfg = getNetCfg() let cfg = getNetCfg()
let proxy: NetworkProxy? = if cfg.socksProxy == nil { let proxy: NetworkProxy? = if cfg.socksProxy == nil {
nil nil
@ -546,7 +546,7 @@ struct MigrateFromDevice: View {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl) migrationState = .linkShown(fileId: fileTransferMeta.fileId, link: data.addToLink(link: rcvURIs[0]), archivePath: archivePath, ctrl: ctrl)
} }
case .sndFileError: case .result(.sndFileError):
alert = .error(title: "Upload failed", error: "Check your internet connection and try again") alert = .error(title: "Upload failed", error: "Check your internet connection and try again")
migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath) migrationState = .uploadFailed(totalBytes: totalBytes, archivePath: archivePath)
default: default:
@ -691,7 +691,7 @@ private struct PassphraseConfirmationView: View {
migrationState = .uploadConfirmation migrationState = .uploadConfirmation
} }
} catch let error { } catch let error {
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse { if case .errorDatabase(.errorOpen(.errorNotADatabase)) = error as? ChatError {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert) showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
} else { } else {
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error))) alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
@ -733,11 +733,11 @@ func chatStoppedView() -> some View {
private class MigrationChatReceiver { private class MigrationChatReceiver {
let ctrl: chat_ctrl let ctrl: chat_ctrl
let databaseUrl: URL let databaseUrl: URL
let processReceivedMsg: (ChatResponse) async -> Void let processReceivedMsg: (APIResult<ChatEvent>) async -> Void
private var receiveLoop: Task<Void, Never>? private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true private var receiveMessages = true
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult<ChatEvent>) async -> Void) {
self.ctrl = ctrl self.ctrl = ctrl
self.databaseUrl = databaseUrl self.databaseUrl = databaseUrl
self.processReceivedMsg = processReceivedMsg self.processReceivedMsg = processReceivedMsg
@ -752,9 +752,9 @@ private class MigrationChatReceiver {
func receiveMsgLoop() async { func receiveMsgLoop() async {
// TODO use function that has timeout // TODO use function that has timeout
if let msg = await chatRecvMsg(ctrl) { if let msg: APIResult<ChatEvent> = await chatRecvMsg(ctrl) {
Task { Task {
await TerminalItems.shared.add(.resp(.now, msg)) await TerminalItems.shared.addResult(msg)
} }
logger.debug("processReceivedMsg: \(msg.responseType)") logger.debug("processReceivedMsg: \(msg.responseType)")
await processReceivedMsg(msg) await processReceivedMsg(msg)

View file

@ -496,10 +496,10 @@ struct MigrateToDevice: View {
chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in chatReceiver = MigrationChatReceiver(ctrl: ctrl, databaseUrl: tempDatabaseUrl) { msg in
await MainActor.run { await MainActor.run {
switch msg { switch msg {
case let .rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer): case let .result(.rcvFileProgressXFTP(_, _, receivedSize, totalSize, rcvFileTransfer)):
migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl) migrationState = .downloadProgress(downloadedBytes: receivedSize, totalBytes: totalSize, fileId: rcvFileTransfer.fileId, link: link, archivePath: archivePath, ctrl: ctrl)
MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) MigrationToDeviceState.save(.downloadProgress(link: link, archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
case .rcvStandaloneFileComplete: case .result(.rcvStandaloneFileComplete):
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
// User closed the whole screen before new state was saved // User closed the whole screen before new state was saved
if migrationState == nil { if migrationState == nil {
@ -509,10 +509,10 @@ struct MigrateToDevice: View {
MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent)) MigrationToDeviceState.save(.archiveImport(archiveName: URL(fileURLWithPath: archivePath).lastPathComponent))
} }
} }
case .rcvFileError: case .result(.rcvFileError):
alert = .error(title: "Download failed", error: "File was deleted or link is invalid") alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
case .chatError(_, .error(.noRcvFileUser)): case .error(.error(.noRcvFileUser)):
alert = .error(title: "Download failed", error: "File was deleted or link is invalid") alert = .error(title: "Download failed", error: "File was deleted or link is invalid")
migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath) migrationState = .downloadFailed(totalBytes: totalBytes, link: link, archivePath: archivePath)
default: default:
@ -539,7 +539,7 @@ struct MigrateToDevice: View {
chatInitControllerRemovingDatabases() chatInitControllerRemovingDatabases()
} else if ChatModel.shared.chatRunning == true { } else if ChatModel.shared.chatRunning == true {
// cannot delete storage if chat is running // cannot delete storage if chat is running
try await apiStopChat() try await stopChatAsync()
} }
try await apiDeleteStorage() try await apiDeleteStorage()
try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true)
@ -623,7 +623,7 @@ struct MigrateToDevice: View {
AlertManager.shared.showAlert( AlertManager.shared.showAlert(
Alert( Alert(
title: Text("Error migrating settings"), title: Text("Error migrating settings"),
message: Text ("Some app settings were not migrated.") + Text("\n") + Text(responseError(error))) message: Text ("Some app settings were not migrated.") + textNewLine + Text(responseError(error)))
) )
} }
hideView() hideView()
@ -632,6 +632,8 @@ struct MigrateToDevice: View {
private func hideView() { private func hideView() {
onboardingStageDefault.set(.onboardingComplete) onboardingStageDefault.set(.onboardingComplete)
m.onboardingStage = .onboardingComplete m.onboardingStage = .onboardingComplete
m.migrationState = nil
MigrationToDeviceState.save(nil)
dismiss() dismiss()
} }
@ -749,11 +751,11 @@ private func progressView() -> some View {
private class MigrationChatReceiver { private class MigrationChatReceiver {
let ctrl: chat_ctrl let ctrl: chat_ctrl
let databaseUrl: URL let databaseUrl: URL
let processReceivedMsg: (ChatResponse) async -> Void let processReceivedMsg: (APIResult<ChatEvent>) async -> Void
private var receiveLoop: Task<Void, Never>? private var receiveLoop: Task<Void, Never>?
private var receiveMessages = true private var receiveMessages = true
init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (ChatResponse) async -> Void) { init(ctrl: chat_ctrl, databaseUrl: URL, _ processReceivedMsg: @escaping (APIResult<ChatEvent>) async -> Void) {
self.ctrl = ctrl self.ctrl = ctrl
self.databaseUrl = databaseUrl self.databaseUrl = databaseUrl
self.processReceivedMsg = processReceivedMsg self.processReceivedMsg = processReceivedMsg
@ -770,7 +772,7 @@ private class MigrationChatReceiver {
// TODO use function that has timeout // TODO use function that has timeout
if let msg = await chatRecvMsg(ctrl) { if let msg = await chatRecvMsg(ctrl) {
Task { Task {
await TerminalItems.shared.add(.resp(.now, msg)) await TerminalItems.shared.addResult(msg)
} }
logger.debug("processReceivedMsg: \(msg.responseType)") logger.debug("processReceivedMsg: \(msg.responseType)")
await processReceivedMsg(msg) await processReceivedMsg(msg)

View file

@ -23,7 +23,7 @@ struct AddGroupView: View {
@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 showInvalidNameAlert = false
@State private var groupLink: String? @State private var groupLink: CreatedConnLink?
@State private var groupLinkMemberRole: GroupMemberRole = .member @State private var groupLinkMemberRole: GroupMemberRole = .member
var body: some View { var body: some View {

View file

@ -85,7 +85,7 @@ struct NewChatSheet: View {
} }
} }
@ViewBuilder private func viewBody(_ showArchive: Bool) -> some View { private func viewBody(_ showArchive: Bool) -> some View {
List { List {
HStack { HStack {
ContactsListSearchBar( ContactsListSearchBar(
@ -258,7 +258,7 @@ struct ContactsList: View {
} }
} }
@ViewBuilder private func noResultSection(text: String) -> some View { private func noResultSection(text: String) -> some View {
Section { Section {
Text(text) Text(text)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)

View file

@ -81,7 +81,8 @@ struct NewChatView: View {
@State var selection: NewChatOption @State var selection: NewChatOption
@State var showQRCodeScanner = false @State var showQRCodeScanner = false
@State private var invitationUsed: Bool = false @State private var invitationUsed: Bool = false
@State private var connReqInvitation: String = "" @State private var connLinkInvitation: CreatedConnLink = CreatedConnLink(connFullLink: "", connShortLink: nil)
@State private var showShortLink = true
@State private var creatingConnReq = false @State private var creatingConnReq = false
@State var choosingProfile = false @State var choosingProfile = false
@State private var pastedLink: String = "" @State private var pastedLink: String = ""
@ -174,11 +175,12 @@ struct NewChatView: View {
private func prepareAndInviteView() -> some View { private func prepareAndInviteView() -> some View {
ZStack { // ZStack is needed for views to not make transitions between each other ZStack { // ZStack is needed for views to not make transitions between each other
if connReqInvitation != "" { if connLinkInvitation.connFullLink != "" {
InviteView( InviteView(
invitationUsed: $invitationUsed, invitationUsed: $invitationUsed,
contactConnection: $contactConnection, contactConnection: $contactConnection,
connReqInvitation: $connReqInvitation, connLinkInvitation: $connLinkInvitation,
showShortLink: $showShortLink,
choosingProfile: $choosingProfile choosingProfile: $choosingProfile
) )
} else if creatingConnReq { } else if creatingConnReq {
@ -190,16 +192,16 @@ struct NewChatView: View {
} }
private func createInvitation() { private func createInvitation() {
if connReqInvitation == "" && contactConnection == nil && !creatingConnReq { if connLinkInvitation.connFullLink == "" && contactConnection == nil && !creatingConnReq {
creatingConnReq = true creatingConnReq = true
Task { Task {
_ = try? await Task.sleep(nanoseconds: 250_000000) _ = try? await Task.sleep(nanoseconds: 250_000000)
let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get()) let (r, apiAlert) = await apiAddContact(incognito: incognitoGroupDefault.get())
if let (connReq, pcc) = r { if let (connLink, pcc) = r {
await MainActor.run { await MainActor.run {
m.updateContactConnection(pcc) m.updateContactConnection(pcc)
m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false) m.showingInvitation = ShowingInvitation(pcc: pcc, connChatUsed: false)
connReqInvitation = connReq connLinkInvitation = connLink
contactConnection = pcc contactConnection = pcc
} }
} else { } else {
@ -243,7 +245,8 @@ private struct InviteView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var invitationUsed: Bool @Binding var invitationUsed: Bool
@Binding var contactConnection: PendingContactConnection? @Binding var contactConnection: PendingContactConnection?
@Binding var connReqInvitation: String @Binding var connLinkInvitation: CreatedConnLink
@Binding var showShortLink: Bool
@Binding var choosingProfile: Bool @Binding var choosingProfile: Bool
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false @AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@ -261,7 +264,7 @@ private struct InviteView: View {
NavigationLink { NavigationLink {
ActiveProfilePicker( ActiveProfilePicker(
contactConnection: $contactConnection, contactConnection: $contactConnection,
connReqInvitation: $connReqInvitation, connLinkInvitation: $connLinkInvitation,
incognitoEnabled: $incognitoDefault, incognitoEnabled: $incognitoDefault,
choosingProfile: $choosingProfile, choosingProfile: $choosingProfile,
selectedProfile: selectedProfile selectedProfile: selectedProfile
@ -296,7 +299,7 @@ private struct InviteView: View {
private func shareLinkView() -> some View { private func shareLinkView() -> some View {
HStack { HStack {
let link = simplexChatLink(connReqInvitation) let link = connLinkInvitation.simplexChatUri(short: showShortLink)
linkTextView(link) linkTextView(link)
Button { Button {
showShareSheet(items: [link]) showShareSheet(items: [link])
@ -310,9 +313,9 @@ private struct InviteView: View {
} }
private func qrCodeView() -> some View { private func qrCodeView() -> some View {
Section(header: Text("Or show this code").foregroundColor(theme.colors.secondary)) { Section {
SimpleXLinkQRCode(uri: connReqInvitation, onShare: setInvitationUsed) SimpleXCreatedLinkQRCode(link: connLinkInvitation, short: $showShortLink, onShare: setInvitationUsed)
.id("simplex-qrcode-view-for-\(connReqInvitation)") .id("simplex-qrcode-view-for-\(connLinkInvitation.simplexChatUri(short: showShortLink))")
.padding() .padding()
.background( .background(
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 12, style: .continuous)
@ -322,6 +325,8 @@ private struct InviteView: View {
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
} header: {
ToggleShortLinkHeader(text: Text("Or show this code"), link: connLinkInvitation, short: $showShortLink)
} }
} }
@ -343,7 +348,7 @@ private struct ActiveProfilePicker: View {
@EnvironmentObject var chatModel: ChatModel @EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var contactConnection: PendingContactConnection? @Binding var contactConnection: PendingContactConnection?
@Binding var connReqInvitation: String @Binding var connLinkInvitation: CreatedConnLink
@Binding var incognitoEnabled: Bool @Binding var incognitoEnabled: Bool
@Binding var choosingProfile: Bool @Binding var choosingProfile: Bool
@State private var alert: SomeAlert? @State private var alert: SomeAlert?
@ -415,12 +420,11 @@ private struct ActiveProfilePicker: View {
} }
Task { Task {
do { do {
if let contactConn = contactConnection, if let contactConn = contactConnection {
let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId) { let conn = try await apiChangeConnectionUser(connId: contactConn.pccConnId, userId: profile.userId)
await MainActor.run { await MainActor.run {
contactConnection = conn contactConnection = conn
connReqInvitation = conn.connReqInv ?? "" connLinkInvitation = conn.connLinkInv ?? CreatedConnLink(connFullLink: "", connShortLink: nil)
incognitoEnabled = false incognitoEnabled = false
chatModel.updateContactConnection(conn) chatModel.updateContactConnection(conn)
} }
@ -502,7 +506,7 @@ private struct ActiveProfilePicker: View {
} }
} }
@ViewBuilder private func profilerPickerUserOption(_ user: User) -> some View { private func profilerPickerUserOption(_ user: User) -> some View {
Button { Button {
if selectedProfile == user && incognitoEnabled { if selectedProfile == user && incognitoEnabled {
incognitoEnabled = false incognitoEnabled = false
@ -836,23 +840,25 @@ func sharedProfileInfo(_ incognito: Bool) -> Text {
} }
enum PlanAndConnectAlert: Identifiable { enum PlanAndConnectAlert: Identifiable {
case ownInvitationLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case ownInvitationLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
case invitationLinkConnecting(connectionLink: String) case invitationLinkConnecting(connectionLink: CreatedConnLink)
case ownContactAddressConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case ownContactAddressConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
case contactAddressConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case contactAddressConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnectingConfirmReconnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool) case groupLinkConnectingConfirmReconnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool)
case groupLinkConnecting(connectionLink: String, groupInfo: GroupInfo?) case groupLinkConnecting(connectionLink: CreatedConnLink, groupInfo: GroupInfo?)
case error(shortOrFullLink: String, alert: Alert)
var id: String { var id: String {
switch self { switch self {
case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink)" case let .ownInvitationLinkConfirmConnect(connectionLink, _, _): return "ownInvitationLinkConfirmConnect \(connectionLink.connFullLink)"
case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink)" case let .invitationLinkConnecting(connectionLink): return "invitationLinkConnecting \(connectionLink.connFullLink)"
case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink)" case let .ownContactAddressConfirmConnect(connectionLink, _, _): return "ownContactAddressConfirmConnect \(connectionLink.connFullLink)"
case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink)" case let .contactAddressConnectingConfirmReconnect(connectionLink, _, _): return "contactAddressConnectingConfirmReconnect \(connectionLink.connFullLink)"
case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink)" case let .groupLinkConfirmConnect(connectionLink, _, _): return "groupLinkConfirmConnect \(connectionLink.connFullLink)"
case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink)" case let .groupLinkConnectingConfirmReconnect(connectionLink, _, _): return "groupLinkConnectingConfirmReconnect \(connectionLink.connFullLink)"
case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink)" case let .groupLinkConnecting(connectionLink, _): return "groupLinkConnecting \(connectionLink.connFullLink)"
case let .error(shortOrFullLink, alert): return "error \(shortOrFullLink)"
} }
} }
} }
@ -935,21 +941,22 @@ func planAndConnectAlert(_ alert: PlanAndConnectAlert, dismiss: Bool, cleanup: (
dismissButton: .default(Text("OK")) { cleanup?() } dismissButton: .default(Text("OK")) { cleanup?() }
) )
} }
case let .error(_, alert): return alert
} }
} }
enum PlanAndConnectActionSheet: Identifiable { enum PlanAndConnectActionSheet: Identifiable {
case askCurrentOrIncognitoProfile(connectionLink: String, connectionPlan: ConnectionPlan?, title: LocalizedStringKey) case askCurrentOrIncognitoProfile(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan?, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileDestructive(connectionLink: String, connectionPlan: ConnectionPlan, title: LocalizedStringKey) case askCurrentOrIncognitoProfileDestructive(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, title: LocalizedStringKey)
case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact) case askCurrentOrIncognitoProfileConnectContactViaAddress(contact: Contact)
case ownGroupLinkConfirmConnect(connectionLink: String, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo) case ownGroupLinkConfirmConnect(connectionLink: CreatedConnLink, connectionPlan: ConnectionPlan, incognito: Bool?, groupInfo: GroupInfo)
var id: String { var id: String {
switch self { switch self {
case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink)" case let .askCurrentOrIncognitoProfile(connectionLink, _, _): return "askCurrentOrIncognitoProfile \(connectionLink.connFullLink)"
case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink)" case let .askCurrentOrIncognitoProfileDestructive(connectionLink, _, _): return "askCurrentOrIncognitoProfileDestructive \(connectionLink.connFullLink)"
case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)" case let .askCurrentOrIncognitoProfileConnectContactViaAddress(contact): return "askCurrentOrIncognitoProfileConnectContactViaAddress \(contact.contactId)"
case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink)" case let .ownGroupLinkConfirmConnect(connectionLink, _, _, _): return "ownGroupLinkConfirmConnect \(connectionLink.connFullLink)"
} }
} }
} }
@ -1008,7 +1015,7 @@ func planAndConnectActionSheet(_ sheet: PlanAndConnectActionSheet, dismiss: Bool
} }
func planAndConnect( func planAndConnect(
_ connectionLink: String, _ shortOrFullLink: String,
showAlert: @escaping (PlanAndConnectAlert) -> Void, showAlert: @escaping (PlanAndConnectAlert) -> Void,
showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void, showActionSheet: @escaping (PlanAndConnectActionSheet) -> Void,
dismiss: Bool, dismiss: Bool,
@ -1018,8 +1025,8 @@ func planAndConnect(
filterKnownGroup: ((GroupInfo) -> Void)? = nil filterKnownGroup: ((GroupInfo) -> Void)? = nil
) { ) {
Task { Task {
do { let (result, alert) = await apiConnectPlan(connLink: shortOrFullLink)
let connectionPlan = try await apiConnectPlan(connReq: connectionLink) if let (connectionLink, connectionPlan) = result {
switch connectionPlan { switch connectionPlan {
case let .invitationLink(ilp): case let .invitationLink(ilp):
switch ilp { switch ilp {
@ -1028,17 +1035,22 @@ func planAndConnect(
if let incognito = incognito { if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else { } else {
await MainActor.run {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link")) showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via one-time link"))
} }
}
case .ownLink: case .ownLink:
logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .invitationLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let incognito = incognito { if let incognito = incognito {
showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) showAlert(.ownInvitationLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else { } else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!")) showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own one-time link!"))
} }
}
case let .connecting(contact_): case let .connecting(contact_):
logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .invitationLink, .connecting, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let contact = contact_ { if let contact = contact_ {
if let f = filterKnownContact { if let f = filterKnownContact {
f(contact) f(contact)
@ -1048,14 +1060,17 @@ func planAndConnect(
} else { } else {
showAlert(.invitationLinkConnecting(connectionLink: connectionLink)) showAlert(.invitationLinkConnecting(connectionLink: connectionLink))
} }
}
case let .known(contact): case let .known(contact):
logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .invitationLink, .known, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let f = filterKnownContact { if let f = filterKnownContact {
f(contact) f(contact)
} else { } else {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
} }
} }
}
case let .contactAddress(cap): case let .contactAddress(cap):
switch cap { switch cap {
case .ok: case .ok:
@ -1063,70 +1078,91 @@ func planAndConnect(
if let incognito = incognito { if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup) connectViaLink(connectionLink, connectionPlan: connectionPlan, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else { } else {
await MainActor.run {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address")) showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect via contact address"))
} }
}
case .ownLink: case .ownLink:
logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .contactAddress, .ownLink, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let incognito = incognito { if let incognito = incognito {
showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) showAlert(.ownContactAddressConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else { } else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!")) showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Connect to yourself?\nThis is your own SimpleX address!"))
} }
}
case .connectingConfirmReconnect: case .connectingConfirmReconnect:
logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .contactAddress, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let incognito = incognito { if let incognito = incognito {
showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) showAlert(.contactAddressConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else { } else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?")) showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You have already requested connection!\nRepeat connection request?"))
} }
}
case let .connectingProhibit(contact): case let .connectingProhibit(contact):
logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .contactAddress, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let f = filterKnownContact { if let f = filterKnownContact {
f(contact) f(contact)
} else { } else {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) } openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyConnectingAlert(contact)) }
} }
}
case let .known(contact): case let .known(contact):
logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .contactAddress, .known, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let f = filterKnownContact { if let f = filterKnownContact {
f(contact) f(contact)
} else { } else {
openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) } openKnownContact(contact, dismiss: dismiss) { AlertManager.shared.showAlert(contactAlreadyExistsAlert(contact)) }
} }
}
case let .contactViaAddress(contact): case let .contactViaAddress(contact):
logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .contactAddress, .contactViaAddress, incognito=\(incognito?.description ?? "nil")")
if let incognito = incognito { if let incognito = incognito {
connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup) connectContactViaAddress_(contact, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else { } else {
await MainActor.run {
showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact)) showActionSheet(.askCurrentOrIncognitoProfileConnectContactViaAddress(contact: contact))
} }
} }
}
case let .groupLink(glp): case let .groupLink(glp):
switch glp { switch glp {
case .ok: case .ok:
await MainActor.run {
if let incognito = incognito { if let incognito = incognito {
showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) showAlert(.groupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else { } else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group")) showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "Join group"))
} }
}
case let .ownLink(groupInfo): case let .ownLink(groupInfo):
logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .groupLink, .ownLink, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let f = filterKnownGroup { if let f = filterKnownGroup {
f(groupInfo) f(groupInfo)
} }
showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo)) showActionSheet(.ownGroupLinkConfirmConnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito, groupInfo: groupInfo))
}
case .connectingConfirmReconnect: case .connectingConfirmReconnect:
logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .groupLink, .connectingConfirmReconnect, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let incognito = incognito { if let incognito = incognito {
showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito)) showAlert(.groupLinkConnectingConfirmReconnect(connectionLink: connectionLink, connectionPlan: connectionPlan, incognito: incognito))
} else { } else {
showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?")) showActionSheet(.askCurrentOrIncognitoProfileDestructive(connectionLink: connectionLink, connectionPlan: connectionPlan, title: "You are already joining the group!\nRepeat join request?"))
} }
}
case let .connectingProhibit(groupInfo_): case let .connectingProhibit(groupInfo_):
logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .groupLink, .connectingProhibit, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_)) showAlert(.groupLinkConnecting(connectionLink: connectionLink, groupInfo: groupInfo_))
}
case let .known(groupInfo): case let .known(groupInfo):
logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")") logger.debug("planAndConnect, .groupLink, .known, incognito=\(incognito?.description ?? "nil")")
await MainActor.run {
if let f = filterKnownGroup { if let f = filterKnownGroup {
f(groupInfo) f(groupInfo)
} else { } else {
@ -1134,14 +1170,19 @@ func planAndConnect(
} }
} }
} }
} catch { case let .error(chatError):
logger.debug("planAndConnect, plan error") logger.debug("planAndConnect, .error \(chatErrorString(chatError))")
if let incognito = incognito { if let incognito = incognito {
connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup) connectViaLink(connectionLink, connectionPlan: nil, dismiss: dismiss, incognito: incognito, cleanup: cleanup)
} else { } else {
showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link")) showActionSheet(.askCurrentOrIncognitoProfile(connectionLink: connectionLink, connectionPlan: nil, title: "Connect via link"))
} }
} }
} else if let alert {
await MainActor.run {
showAlert(.error(shortOrFullLink: shortOrFullLink, alert: alert))
}
}
} }
} }
@ -1161,22 +1202,22 @@ private func connectContactViaAddress_(_ contact: Contact, dismiss: Bool, incogn
} }
private func connectViaLink( private func connectViaLink(
_ connectionLink: String, _ connectionLink: CreatedConnLink,
connectionPlan: ConnectionPlan?, connectionPlan: ConnectionPlan?,
dismiss: Bool, dismiss: Bool,
incognito: Bool, incognito: Bool,
cleanup: (() -> Void)? cleanup: (() -> Void)?
) { ) {
Task { Task {
if let (connReqType, pcc) = await apiConnect(incognito: incognito, connReq: connectionLink) { if let (connReqType, pcc) = await apiConnect(incognito: incognito, connLink: connectionLink) {
await MainActor.run { await MainActor.run {
ChatModel.shared.updateContactConnection(pcc) ChatModel.shared.updateContactConnection(pcc)
} }
let crt: ConnReqType let crt: ConnReqType
if let plan = connectionPlan { crt = if let plan = connectionPlan {
crt = planToConnReqType(plan) planToConnReqType(plan) ?? connReqType
} else { } else {
crt = connReqType connReqType
} }
DispatchQueue.main.async { DispatchQueue.main.async {
if dismiss { if dismiss {
@ -1199,42 +1240,38 @@ private func connectViaLink(
} }
func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { func openKnownContact(_ contact: Contact, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared let m = ChatModel.shared
if let c = m.getContactChat(contact.contactId) { if let c = m.getContactChat(contact.contactId) {
DispatchQueue.main.async {
if dismiss { if dismiss {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(c.id) ItemsModel.shared.loadOpenChat(c.id) {
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
}
} else { } else {
ItemsModel.shared.loadOpenChat(c.id) ItemsModel.shared.loadOpenChat(c.id) {
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
} }
} }
} }
}
func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) { func openKnownGroup(_ groupInfo: GroupInfo, dismiss: Bool, showAlreadyExistsAlert: (() -> Void)?) {
Task {
let m = ChatModel.shared let m = ChatModel.shared
if let g = m.getGroupChat(groupInfo.groupId) { if let g = m.getGroupChat(groupInfo.groupId) {
DispatchQueue.main.async {
if dismiss { if dismiss {
dismissAllSheets(animated: true) { dismissAllSheets(animated: true) {
ItemsModel.shared.loadOpenChat(g.id) ItemsModel.shared.loadOpenChat(g.id) {
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
}
} else { } else {
ItemsModel.shared.loadOpenChat(g.id) ItemsModel.shared.loadOpenChat(g.id) {
showAlreadyExistsAlert?() showAlreadyExistsAlert?()
} }
} }
} }
} }
}
func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert { func contactAlreadyConnectingAlert(_ contact: Contact) -> Alert {
mkAlert( mkAlert(
@ -1269,11 +1306,12 @@ enum ConnReqType: Equatable {
} }
} }
private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType { private func planToConnReqType(_ connectionPlan: ConnectionPlan) -> ConnReqType? {
switch connectionPlan { switch connectionPlan {
case .invitationLink: return .invitation case .invitationLink: .invitation
case .contactAddress: return .contact case .contactAddress: .contact
case .groupLink: return .groupLink case .groupLink: .groupLink
case .error: nil
} }
} }

View file

@ -8,6 +8,7 @@
import SwiftUI import SwiftUI
import CoreImage.CIFilterBuiltins import CoreImage.CIFilterBuiltins
import SimpleXChat
struct MutableQRCode: View { struct MutableQRCode: View {
@Binding var uri: String @Binding var uri: String
@ -20,6 +21,16 @@ struct MutableQRCode: View {
} }
} }
struct SimpleXCreatedLinkQRCode: View {
let link: CreatedConnLink
@Binding var short: Bool
var onShare: (() -> Void)? = nil
var body: some View {
QRCode(uri: link.simplexChatUri(short: short), onShare: onShare)
}
}
struct SimpleXLinkQRCode: View { struct SimpleXLinkQRCode: View {
let uri: String let uri: String
var withLogo: Bool = true var withLogo: Bool = true
@ -31,12 +42,6 @@ struct SimpleXLinkQRCode: View {
} }
} }
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

View file

@ -43,26 +43,23 @@ struct OnboardingButtonStyle: ButtonStyle {
} }
} }
private enum ChooseServerOperatorsSheet: Identifiable { private enum OnboardingConditionsViewSheet: Identifiable {
case showInfo
case showConditions case showConditions
case configureOperators
var id: String { var id: String {
switch self { switch self {
case .showInfo: return "showInfo"
case .showConditions: return "showConditions" case .showConditions: return "showConditions"
case .configureOperators: return "configureOperators"
} }
} }
} }
struct ChooseServerOperators: View { struct OnboardingConditionsView: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
var onboarding: Bool
@State private var serverOperators: [ServerOperator] = [] @State private var serverOperators: [ServerOperator] = []
@State private var selectedOperatorIds = Set<Int64>() @State private var selectedOperatorIds = Set<Int64>()
@State private var sheetItem: ChooseServerOperatorsSheet? = nil @State private var sheetItem: OnboardingConditionsViewSheet? = nil
@State private var notificationsModeNavLinkActive = false @State private var notificationsModeNavLinkActive = false
@State private var justOpened = true @State private var justOpened = true
@ -70,83 +67,47 @@ struct ChooseServerOperators: View {
var body: some View { var body: some View {
GeometryReader { g in GeometryReader { g in
ScrollView { let v = ScrollView {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
let title = Text("Server operators") Text("Conditions of use")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 25)
if onboarding {
title.padding(.top, 25)
} else {
title
}
infoText()
.frame(maxWidth: .infinity, alignment: .center)
Spacer() Spacer()
ForEach(serverOperators) { srvOperator in VStack(alignment: .leading, spacing: 20) {
operatorCheckView(srvOperator) Text("Private chats, groups and your contacts are not accessible to server operators.")
} .lineSpacing(2)
VStack { .frame(maxWidth: .infinity, alignment: .leading)
Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8) Text("""
Text("You can configure servers via settings.") By using SimpleX Chat you agree to:
} - send only legal content in public groups.
.font(.footnote) - respect other users no spam.
.multilineTextAlignment(.center) """)
.frame(maxWidth: .infinity, alignment: .center) .lineSpacing(2)
.padding(.horizontal, 16) .frame(maxWidth: .infinity, alignment: .leading)
Spacer() Button("Privacy policy and conditions of use.") {
let reviewForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
let canReviewLater = reviewForOperators.allSatisfy { $0.conditionsAcceptance.usageAllowed }
let currEnabledOperatorIds = Set(serverOperators.filter { $0.enabled }.map { $0.operatorId })
VStack(spacing: 8) {
if !reviewForOperators.isEmpty {
reviewConditionsButton()
} else if selectedOperatorIds != currEnabledOperatorIds && !selectedOperatorIds.isEmpty {
setOperatorsButton()
} else {
continueButton()
}
if onboarding {
Group {
if reviewForOperators.isEmpty {
Button("Conditions of use") {
sheetItem = .showConditions sheetItem = .showConditions
} }
} else { .frame(maxWidth: .infinity, alignment: .leading)
Text("Conditions of use")
.foregroundColor(.clear)
} }
.padding(.horizontal, 4)
Spacer()
VStack(spacing: 12) {
acceptConditionsButton()
Button("Configure server operators") {
sheetItem = .configureOperators
} }
.font(.system(size: 17, weight: .semibold))
.frame(minHeight: 40) .frame(minHeight: 40)
} }
} }
.padding(25)
if !onboarding && !reviewForOperators.isEmpty {
VStack(spacing: 8) {
reviewLaterButton()
(
Text("Conditions will be accepted for enabled operators after 30 days.")
+ textSpace
+ Text("You can configure operators in Network & servers settings.")
)
.multilineTextAlignment(.center)
.font(.footnote)
.padding(.horizontal, 32)
}
.frame(maxWidth: .infinity)
.disabled(!canReviewLater)
.padding(.bottom)
}
}
.frame(minHeight: g.size.height) .frame(minHeight: g.size.height)
} }
.onAppear { .onAppear {
@ -158,131 +119,28 @@ struct ChooseServerOperators: View {
} }
.sheet(item: $sheetItem) { item in .sheet(item: $sheetItem) { item in
switch item { switch item {
case .showInfo:
ChooseServerOperatorsInfoView()
case .showConditions: case .showConditions:
UsageConditionsView( SimpleConditionsView()
currUserServers: Binding.constant([]),
userServers: Binding.constant([]),
updated: false
)
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
case .configureOperators:
ChooseServerOperators(serverOperators: serverOperators, selectedOperatorIds: $selectedOperatorIds)
.modifier(ThemedBackground())
} }
} }
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
if #available(iOS 16.4, *) {
v.scrollBounceBehavior(.basedOnSize)
} else {
v
}
} }
.frame(maxHeight: .infinity, alignment: .top) .frame(maxHeight: .infinity, alignment: .top)
.padding(onboarding ? 25 : 16) .navigationBarHidden(true) // necessary on iOS 15
}
private func infoText() -> some View {
Button {
sheetItem = .showInfo
} label: {
Label("How it helps privacy", systemImage: "info.circle")
.font(.headline)
}
}
@ViewBuilder private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
let checked = selectedOperatorIds.contains(serverOperator.operatorId)
let icon = checked ? "checkmark.circle.fill" : "circle"
let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
HStack(spacing: 10) {
Image(serverOperator.largeLogo(colorScheme))
.resizable()
.scaledToFit()
.frame(height: 48)
Spacer()
Image(systemName: icon)
.resizable()
.scaledToFit()
.frame(width: 26, height: 26)
.foregroundColor(iconColor)
}
.background(theme.colors.background)
.padding()
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
.padding(.horizontal, 2)
.onTapGesture {
if checked {
selectedOperatorIds.remove(serverOperator.operatorId)
} else {
selectedOperatorIds.insert(serverOperator.operatorId)
}
}
}
private func reviewConditionsButton() -> some View {
NavigationLink("Review conditions") {
reviewConditionsView()
.navigationTitle("Conditions of use")
.navigationBarTitleDisplayMode(.large)
.toolbar { ToolbarItem(placement: .navigationBarTrailing, content: conditionsLinkButton) }
.modifier(ThemedBackground(grouped: true))
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
}
private func setOperatorsButton() -> some View {
notificationsModeNavLinkButton {
Button {
Task {
if let enabledOperators = enabledOperators(serverOperators) {
let r = try await setServerOperators(operators: enabledOperators)
await MainActor.run {
ChatModel.shared.conditions = r
continueToNextStep()
}
} else {
await MainActor.run {
continueToNextStep()
}
}
}
} label: {
Text("Update")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
}
}
private func continueButton() -> some View {
notificationsModeNavLinkButton {
Button {
continueToNextStep()
} label: {
Text("Continue")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
}
}
private func reviewLaterButton() -> some View {
notificationsModeNavLinkButton {
Button {
continueToNextStep()
} label: {
Text("Review later")
}
.buttonStyle(.borderless)
}
} }
private func continueToNextStep() { private func continueToNextStep() {
if onboarding {
onboardingStageDefault.set(.step4_SetNotificationsMode) onboardingStageDefault.set(.step4_SetNotificationsMode)
notificationsModeNavLinkActive = true notificationsModeNavLinkActive = true
} else {
dismiss()
}
} }
func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View { func notificationsModeNavLinkButton(_ button: @escaping (() -> some View)) -> some View {
@ -305,25 +163,6 @@ struct ChooseServerOperators: View {
.modifier(ThemedBackground()) .modifier(ThemedBackground())
} }
@ViewBuilder private func reviewConditionsView() -> some View {
let operatorsWithConditionsAccepted = ChatModel.shared.conditions.serverOperators.filter { $0.conditionsAcceptance.conditionsAccepted }
let acceptForOperators = selectedOperators.filter { !$0.conditionsAcceptance.conditionsAccepted }
VStack(alignment: .leading, spacing: 20) {
if !operatorsWithConditionsAccepted.isEmpty {
Text("Conditions are already accepted for these operator(s): **\(operatorsWithConditionsAccepted.map { $0.legalName_ }.joined(separator: ", "))**.")
Text("The same conditions will apply to operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
} else {
Text("Conditions will be accepted for operator(s): **\(acceptForOperators.map { $0.legalName_ }.joined(separator: ", "))**.")
}
ConditionsTextView()
.frame(maxHeight: .infinity)
acceptConditionsButton()
.padding(.bottom)
.padding(.bottom)
}
.padding(.horizontal, 25)
}
private func acceptConditionsButton() -> some View { private func acceptConditionsButton() -> some View {
notificationsModeNavLinkButton { notificationsModeNavLinkButton {
Button { Button {
@ -357,9 +196,10 @@ struct ChooseServerOperators: View {
} }
} }
} label: { } label: {
Text("Accept conditions") Text("Accept")
} }
.buttonStyle(OnboardingButtonStyle()) .buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
} }
} }
@ -394,6 +234,126 @@ struct ChooseServerOperators: View {
} }
} }
private enum ChooseServerOperatorsSheet: Identifiable {
case showInfo
var id: String {
switch self {
case .showInfo: return "showInfo"
}
}
}
struct ChooseServerOperators: View {
@Environment(\.dismiss) var dismiss: DismissAction
@Environment(\.colorScheme) var colorScheme: ColorScheme
@EnvironmentObject var theme: AppTheme
var serverOperators: [ServerOperator]
@Binding var selectedOperatorIds: Set<Int64>
@State private var sheetItem: ChooseServerOperatorsSheet? = nil
var body: some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .leading, spacing: 20) {
Text("Server operators")
.font(.largeTitle)
.bold()
.frame(maxWidth: .infinity, alignment: .center)
.padding(.top, 25)
infoText()
.frame(maxWidth: .infinity, alignment: .center)
Spacer()
ForEach(serverOperators) { srvOperator in
operatorCheckView(srvOperator)
}
VStack {
Text("SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.").padding(.bottom, 8)
Text("You can configure servers via settings.")
}
.font(.footnote)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 16)
Spacer()
VStack(spacing: 8) {
setOperatorsButton()
onboardingButtonPlaceholder()
}
}
.frame(minHeight: g.size.height)
}
.sheet(item: $sheetItem) { item in
switch item {
case .showInfo:
ChooseServerOperatorsInfoView()
}
}
.frame(maxHeight: .infinity, alignment: .top)
}
.frame(maxHeight: .infinity, alignment: .top)
.padding(25)
.interactiveDismissDisabled(selectedOperatorIds.isEmpty)
}
private func infoText() -> some View {
Button {
sheetItem = .showInfo
} label: {
Label("How it helps privacy", systemImage: "info.circle")
.font(.headline)
}
}
private func operatorCheckView(_ serverOperator: ServerOperator) -> some View {
let checked = selectedOperatorIds.contains(serverOperator.operatorId)
let icon = checked ? "checkmark.circle.fill" : "circle"
let iconColor = checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme)
return HStack(spacing: 10) {
Image(serverOperator.largeLogo(colorScheme))
.resizable()
.scaledToFit()
.frame(height: 48)
Spacer()
Image(systemName: icon)
.resizable()
.scaledToFit()
.frame(width: 26, height: 26)
.foregroundColor(iconColor)
}
.background(theme.colors.background)
.padding()
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(
RoundedRectangle(cornerRadius: 18)
.stroke(Color(uiColor: .secondarySystemFill), lineWidth: 2)
)
.padding(.horizontal, 2)
.onTapGesture {
if checked {
selectedOperatorIds.remove(serverOperator.operatorId)
} else {
selectedOperatorIds.insert(serverOperator.operatorId)
}
}
}
private func setOperatorsButton() -> some View {
Button {
dismiss()
} label: {
Text("OK")
}
.buttonStyle(OnboardingButtonStyle(isDisabled: selectedOperatorIds.isEmpty))
.disabled(selectedOperatorIds.isEmpty)
}
}
let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")! let operatorsPostLink = URL(string: "https://simplex.chat/blog/20241125-servers-operated-by-flux-true-privacy-and-decentralization-for-all-users.html")!
struct ChooseServerOperatorsInfoView: View { struct ChooseServerOperatorsInfoView: View {
@ -448,5 +408,5 @@ struct ChooseServerOperatorsInfoView: View {
} }
#Preview { #Preview {
ChooseServerOperators(onboarding: true) OnboardingConditionsView()
} }

View file

@ -62,8 +62,7 @@ struct CreateProfile: View {
.frame(height: 20) .frame(height: 20)
} footer: { } footer: {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Your profile, contacts and delivered messages are stored on your device.") Text("Your profile is stored on your device and only shared with your contacts.")
Text("The profile is only shared with your contacts.")
} }
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -118,25 +117,22 @@ struct CreateFirstProfile: View {
@State private var nextStepNavLinkActive = false @State private var nextStepNavLinkActive = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { let v = VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .center, spacing: 20) { VStack(alignment: .center, spacing: 16) {
Text("Create your profile") Text("Create profile")
.font(.largeTitle) .font(.largeTitle)
.bold() .bold()
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
Text("Your profile, contacts and delivered messages are stored on your device.") Text("Your profile is stored on your device and only shared with your contacts.")
.font(.callout)
.foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center)
Text("The profile is only shared with your contacts.")
.font(.callout) .font(.callout)
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
} }
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity) // Ensures it takes up the full width .frame(maxWidth: .infinity) // Ensures it takes up the full width
.padding(.horizontal, 10) .padding(.horizontal, 10)
.onTapGesture { focusDisplayName = false }
HStack { HStack {
let name = displayName.trimmingCharacters(in: .whitespaces) let name = displayName.trimmingCharacters(in: .whitespaces)
@ -145,6 +141,7 @@ struct CreateFirstProfile: View {
TextField("Enter your name…", text: $displayName) TextField("Enter your name…", text: $displayName)
.focused($focusDisplayName) .focused($focusDisplayName)
.padding(.horizontal) .padding(.horizontal)
.padding(.trailing, 20)
.padding(.vertical, 10) .padding(.vertical, 10)
.background( .background(
RoundedRectangle(cornerRadius: 10, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
@ -173,12 +170,23 @@ struct CreateFirstProfile: View {
} }
} }
.onAppear() { .onAppear() {
if #available(iOS 16, *) {
focusDisplayName = true
} else {
// it does not work before animation completes on iOS 15
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
focusDisplayName = true focusDisplayName = true
} }
}
}
.padding(.horizontal, 25) .padding(.horizontal, 25)
.padding(.top, 10)
.padding(.bottom, 25) .padding(.bottom, 25)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if #available(iOS 16, *) {
return v.padding(.top, 10)
} else {
return v.padding(.top, 75).ignoresSafeArea(.all, edges: .top)
}
} }
func createProfileButton() -> some View { func createProfileButton() -> some View {
@ -206,7 +214,7 @@ struct CreateFirstProfile: View {
} }
private func nextStepDestinationView() -> some View { private func nextStepDestinationView() -> some View {
ChooseServerOperators(onboarding: true) OnboardingConditionsView()
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.modifier(ThemedBackground()) .modifier(ThemedBackground())
} }
@ -235,15 +243,15 @@ private func showCreateProfileAlert(
_ error: Error _ error: Error
) { ) {
let m = ChatModel.shared let m = ChatModel.shared
switch error as? ChatResponse { switch error as? ChatError {
case .chatCmdError(_, .errorStore(.duplicateName)), case .errorStore(.duplicateName),
.chatCmdError(_, .error(.userExists)): .error(.userExists):
if m.currentUser == nil { if m.currentUser == nil {
AlertManager.shared.showAlert(duplicateUserAlert) AlertManager.shared.showAlert(duplicateUserAlert)
} else { } else {
showAlert(.duplicateUserError) showAlert(.duplicateUserError)
} }
case .chatCmdError(_, .error(.invalidDisplayName)): case .error(.invalidDisplayName):
if m.currentUser == nil { if m.currentUser == nil {
AlertManager.shared.showAlert(invalidDisplayNameAlert) AlertManager.shared.showAlert(invalidDisplayNameAlert)
} else { } else {

View file

@ -31,7 +31,7 @@ struct CreateSimpleXAddress: View {
Spacer() Spacer()
if let userAddress = m.userAddress { if let userAddress = m.userAddress {
SimpleXLinkQRCode(uri: userAddress.connReqContact) SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: Binding.constant(false))
.frame(maxHeight: g.size.width) .frame(maxHeight: g.size.width)
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@ -77,9 +77,9 @@ struct CreateSimpleXAddress: View {
progressIndicator = true progressIndicator = true
Task { Task {
do { do {
let connReqContact = try await apiCreateUserAddress() let connLinkContact = try await apiCreateUserAddress(short: false)
DispatchQueue.main.async { DispatchQueue.main.async {
m.userAddress = UserContactLink(connReqContact: connReqContact) m.userAddress = UserContactLink(connLinkContact: connLinkContact)
} }
await MainActor.run { progressIndicator = false } await MainActor.run { progressIndicator = false }
} catch let error { } catch let error {
@ -121,7 +121,7 @@ struct CreateSimpleXAddress: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button { Button {
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false))])
} label: { } label: {
Label("Share", systemImage: "square.and.arrow.up") Label("Share", systemImage: "square.and.arrow.up")
} }
@ -189,7 +189,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"), simplexChatLink(userAddress.connReqContact)) """, comment: "email text"), simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: false)))
MailView( MailView(
isShowing: self.$showMailView, isShowing: self.$showMailView,
result: $mailViewResult, result: $mailViewResult,

View file

@ -23,7 +23,7 @@ struct OnboardingView: View {
case .step3_CreateSimpleXAddress: // deprecated case .step3_CreateSimpleXAddress: // deprecated
CreateSimpleXAddress() CreateSimpleXAddress()
case .step3_ChooseServerOperators: case .step3_ChooseServerOperators:
ChooseServerOperators(onboarding: true) OnboardingConditionsView()
.navigationBarBackButtonHidden(true) .navigationBarBackButtonHidden(true)
.modifier(ThemedBackground()) .modifier(ThemedBackground())
case .step4_SetNotificationsMode: case .step4_SetNotificationsMode:
@ -44,7 +44,7 @@ enum OnboardingStage: String, Identifiable {
case step1_SimpleXInfo case step1_SimpleXInfo
case step2_CreateProfile // deprecated case step2_CreateProfile // deprecated
case step3_CreateSimpleXAddress // deprecated case step3_CreateSimpleXAddress // deprecated
case step3_ChooseServerOperators case step3_ChooseServerOperators // changed to simplified conditions
case step4_SetNotificationsMode case step4_SetNotificationsMode
case onboardingComplete case onboardingComplete

View file

@ -17,7 +17,7 @@ struct SetNotificationsMode: View {
var body: some View { var body: some View {
GeometryReader { g in GeometryReader { g in
ScrollView { let v = ScrollView {
VStack(alignment: .center, spacing: 20) { VStack(alignment: .center, spacing: 20) {
Text("Push notifications") Text("Push notifications")
.font(.largeTitle) .font(.largeTitle)
@ -57,11 +57,17 @@ struct SetNotificationsMode: View {
.padding(25) .padding(25)
.frame(minHeight: g.size.height) .frame(minHeight: g.size.height)
} }
if #available(iOS 16.4, *) {
v.scrollBounceBehavior(.basedOnSize)
} else {
v
}
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.sheet(isPresented: $showInfo) { .sheet(isPresented: $showInfo) {
NotificationsInfoView() NotificationsInfoView()
} }
.navigationBarHidden(true) // necessary on iOS 15
} }
private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) { private func setNotificationsMode(_ token: DeviceToken, _ mode: NotificationsMode) {

View file

@ -18,7 +18,7 @@ struct SimpleXInfo: View {
var body: some View { var body: some View {
GeometryReader { g in GeometryReader { g in
ScrollView { let v = ScrollView {
VStack(alignment: .leading) { VStack(alignment: .leading) {
VStack(alignment: .center, spacing: 10) { VStack(alignment: .center, spacing: 10) {
Image(colorScheme == .light ? "logo" : "logo-light") Image(colorScheme == .light ? "logo" : "logo-light")
@ -66,6 +66,9 @@ struct SimpleXInfo: View {
} }
} }
} }
.padding(.horizontal, 25)
.padding(.top, 75)
.padding(.bottom, 25)
.frame(minHeight: g.size.height) .frame(minHeight: g.size.height)
} }
.sheet(isPresented: Binding( .sheet(isPresented: Binding(
@ -88,14 +91,17 @@ struct SimpleXInfo: View {
createProfileNavLinkActive: $createProfileNavLinkActive createProfileNavLinkActive: $createProfileNavLinkActive
) )
} }
if #available(iOS 16.4, *) {
v.scrollBounceBehavior(.basedOnSize)
} else {
v
}
} }
.onAppear() { .onAppear() {
setLastVersionDefault() setLastVersionDefault()
} }
.frame(maxHeight: .infinity) .frame(maxHeight: .infinity)
.padding(.horizontal, 25) .navigationBarHidden(true) // necessary on iOS 15
.padding(.top, 75)
.padding(.bottom, 25)
} }
private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View { private func onboardingInfoRow(_ image: String, _ title: LocalizedStringKey, _ text: LocalizedStringKey, width: CGFloat) -> some View {
@ -129,6 +135,7 @@ struct SimpleXInfo: View {
NavigationLink(isActive: $createProfileNavLinkActive) { NavigationLink(isActive: $createProfileNavLinkActive) {
CreateFirstProfile() CreateFirstProfile()
.modifier(ThemedBackground())
} label: { } label: {
EmptyView() EmptyView()
} }
@ -140,6 +147,8 @@ struct SimpleXInfo: View {
let textSpace = Text(verbatim: " ") let textSpace = Text(verbatim: " ")
let textNewLine = Text(verbatim: "\n")
struct SimpleXInfo_Previews: PreviewProvider { struct SimpleXInfo_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
SimpleXInfo(onboarding: true) SimpleXInfo(onboarding: true)

View file

@ -542,7 +542,7 @@ private let versionDescriptions: [VersionDescription] = [
), ),
VersionDescription( VersionDescription(
version: "v6.3", version: "v6.3",
// post: URL(string: "https://simplex.chat/blog/20241210-simplex-network-v6-2-servers-by-flux-business-chats.html"), post: URL(string: "https://simplex.chat/blog/20250308-simplex-chat-v6-3-new-user-experience-safety-in-public-groups.html"),
features: [ features: [
.feature(Description( .feature(Description(
icon: "at", icon: "at",
@ -594,8 +594,6 @@ func shouldShowWhatsNew() -> Bool {
} }
fileprivate struct NewOperatorsView: View { fileprivate struct NewOperatorsView: View {
@State private var showOperatorsSheet = false
var body: some View { var body: some View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo) Image((operatorsInfo[.flux] ?? ServerOperator.dummyOperatorInfo).largeLogo)
@ -606,16 +604,7 @@ fileprivate struct NewOperatorsView: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(10) .lineLimit(10)
HStack { HStack {
Button("Enable Flux") { Text("Enable Flux in Network & servers settings for better metadata privacy.")
showOperatorsSheet = true
}
Text("for better metadata privacy.")
}
}
.sheet(isPresented: $showOperatorsSheet) {
NavigationView {
ChooseServerOperators(onboarding: false)
.modifier(ThemedBackground())
} }
} }
} }
@ -647,8 +636,7 @@ struct WhatsNewView: View {
case .showConditions: case .showConditions:
UsageConditionsView( UsageConditionsView(
currUserServers: Binding.constant([]), currUserServers: Binding.constant([]),
userServers: Binding.constant([]), userServers: Binding.constant([])
updated: true
) )
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} }

View file

@ -456,12 +456,12 @@ struct ConnectDesktopView: View {
} }
} catch let e { } catch let e {
await MainActor.run { await MainActor.run {
switch e as? ChatResponse { switch e as? ChatError {
case .chatCmdError(_, .errorRemoteCtrl(.badInvitation)): alert = .badInvitationError case .errorRemoteCtrl(.badInvitation): alert = .badInvitationError
case .chatCmdError(_, .error(.commandError)): alert = .badInvitationError case .error(.commandError): alert = .badInvitationError
case let .chatCmdError(_, .errorRemoteCtrl(.badVersion(v))): alert = .badVersionError(version: v) case let .errorRemoteCtrl(.badVersion(v)): alert = .badVersionError(version: v)
case .chatCmdError(_, .errorAgent(.RCP(.version))): alert = .badVersionError(version: nil) case .errorAgent(.RCP(.version)): alert = .badVersionError(version: nil)
case .chatCmdError(_, .errorAgent(.RCP(.ctrlAuth))): alert = .desktopDisconnectedError case .errorAgent(.RCP(.ctrlAuth)): alert = .desktopDisconnectedError
default: errorAlert(e) default: errorAlert(e)
} }
} }

View file

@ -145,18 +145,18 @@ struct TerminalView: View {
} }
func consoleSendMessage() { func consoleSendMessage() {
let cmd = ChatCommand.string(composeState.message)
if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) {
let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) let resp: APIResult<ChatResponse2> = APIResult.error(ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty")))
Task { Task {
await TerminalItems.shared.addCommand(.now, cmd, resp) await TerminalItems.shared.addCommand(.now, .string(composeState.message), resp)
} }
} else { } else {
let cmd = composeState.message
DispatchQueue.global().async { DispatchQueue.global().async {
Task { Task {
composeState.inProgress = true await MainActor.run { composeState.inProgress = true }
_ = await chatSendCmd(cmd) await sendTerminalCmd(cmd)
composeState.inProgress = false await MainActor.run { composeState.inProgress = false }
} }
} }
} }
@ -164,12 +164,38 @@ struct TerminalView: View {
} }
} }
func sendTerminalCmd(_ cmd: String) async {
let start: Date = .now
await withCheckedContinuation { (cont: CheckedContinuation<Void, Never>) in
let d = sendSimpleXCmdStr(cmd)
Task {
guard let d else {
await TerminalItems.shared.addCommand(start, ChatCommand.string(cmd), APIResult<ChatResponse2>.error(.invalidJSON(json: nil)))
return
}
let r0: APIResult<ChatResponse0> = decodeAPIResult(d)
guard case .invalid = r0 else {
await TerminalItems.shared.addCommand(start, .string(cmd), r0)
return
}
let r1: APIResult<ChatResponse1> = decodeAPIResult(d)
guard case .invalid = r1 else {
await TerminalItems.shared.addCommand(start, .string(cmd), r1)
return
}
let r2: APIResult<ChatResponse2> = decodeAPIResult(d)
await TerminalItems.shared.addCommand(start, .string(cmd), r2)
}
cont.resume(returning: ())
}
}
struct TerminalView_Previews: PreviewProvider { struct TerminalView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.terminalItems = [ chatModel.terminalItems = [
.resp(.now, ChatResponse.response(type: "contactSubscribed", json: "{}")), .err(.now, APIResult<ChatResponse2>.invalid(type: "contactSubscribed", json: "{}".data(using: .utf8)!).unexpected),
.resp(.now, ChatResponse.response(type: "newChatItems", json: "{}")) .err(.now, APIResult<ChatResponse2>.invalid(type: "newChatItems", json: "{}".data(using: .utf8)!).unexpected)
] ]
return NavigationView { return NavigationView {
TerminalView() TerminalView()

View file

@ -38,7 +38,6 @@ extension AppSettings {
privacyLinkPreviewsGroupDefault.set(val) privacyLinkPreviewsGroupDefault.set(val)
def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) def.setValue(val, forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
} }
if let val = privacyChatListOpenLinks { privacyChatListOpenLinksDefault.set(val) }
if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) } if let val = privacyShowChatPreviews { def.setValue(val, forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) }
if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) } if let val = privacySaveLastDraft { def.setValue(val, forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) }
if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) } if let val = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
@ -78,7 +77,6 @@ extension AppSettings {
c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get() c.privacyAskToApproveRelays = privacyAskToApproveRelaysGroupDefault.get()
c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get() c.privacyAcceptImages = privacyAcceptImagesGroupDefault.get()
c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS) c.privacyLinkPreviews = def.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS)
c.privacyChatListOpenLinks = privacyChatListOpenLinksDefault.get()
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT) c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)

View file

@ -19,7 +19,7 @@ struct MarkdownHelp: View {
mdFormat("_italic_", Text("italic").italic()) mdFormat("_italic_", Text("italic").italic())
mdFormat("~strike~", Text("strike").strikethrough()) mdFormat("~strike~", Text("strike").strikethrough())
mdFormat("`a + b`", Text("`a + b`").font(.body.monospaced())) mdFormat("`a + b`", Text("`a + b`").font(.body.monospaced()))
mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(" (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(")")) mdFormat("!1 colored!", Text("colored").foregroundColor(.red) + Text(verbatim: " (") + color("1", .red) + color("2", .green) + color("3", .blue) + color("4", .yellow) + color("5", .cyan) + Text("6").foregroundColor(.purple) + Text(verbatim: ")"))
( (
mdFormat("#secret#", Text("secret") mdFormat("#secret#", Text("secret")
.foregroundColor(.clear) .foregroundColor(.clear)
@ -39,7 +39,7 @@ private func mdFormat(_ format: LocalizedStringKey, _ example: Text) -> some Vie
} }
private func color(_ s: String, _ c: Color) -> Text { private func color(_ s: String, _ c: Color) -> Text {
Text(s).foregroundColor(c) + Text(", ") Text(s).foregroundColor(c) + Text(verbatim: ", ")
} }
struct MarkdownHelp_Previews: PreviewProvider { struct MarkdownHelp_Previews: PreviewProvider {

View file

@ -209,11 +209,16 @@ struct AdvancedNetworkSettings: View {
} }
Section { Section {
Toggle("Use web port", isOn: $netCfg.smpWebPort) Picker("Use web port", selection: $netCfg.smpWebPortServers) {
ForEach(SMPWebPortServers.allCases, id: \.self) { Text($0.text) }
}
.frame(height: 36)
} header: { } header: {
Text("TCP port for messaging") Text("TCP port for messaging")
} footer: { } footer: {
Text("Use TCP port \(netCfg.smpWebPort ? "443" : "5223") when no port is specified.") netCfg.smpWebPortServers == .preset
? Text("Use TCP port 443 for preset servers only.")
: Text("Use TCP port \(netCfg.smpWebPortServers == .all ? "443" : "5223") when no port is specified.")
} }
Section("TCP connection") { Section("TCP connection") {
@ -368,8 +373,8 @@ struct AdvancedNetworkSettings: View {
let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.") let userMode = Text("A separate TCP connection will be used **for each chat profile you have in the app**.")
return switch mode { return switch mode {
case .user: userMode case .user: userMode
case .session: userMode + Text("\n") + Text("New SOCKS credentials will be used every time you start the app.") case .session: userMode + textNewLine + Text("New SOCKS credentials will be used every time you start the app.")
case .server: userMode + Text("\n") + Text("New SOCKS credentials will be used for each server.") case .server: userMode + textNewLine + Text("New SOCKS credentials will be used for each server.")
case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.") case .entity: Text("A separate TCP connection will be used **for each contact and group member**.\n**Please note**: if you have many connections, your battery and traffic consumption can be substantially higher and some connections may fail.")
} }
} }

View file

@ -20,11 +20,11 @@ private enum NetworkAlert: Identifiable {
} }
private enum NetworkAndServersSheet: Identifiable { private enum NetworkAndServersSheet: Identifiable {
case showConditions(updated: Bool) case showConditions
var id: String { var id: String {
switch self { switch self {
case let .showConditions(updated): return "showConditions \(updated)" case .showConditions: return "showConditions"
} }
} }
} }
@ -169,11 +169,10 @@ struct NetworkAndServers: View {
} }
.sheet(item: $sheetItem) { item in .sheet(item: $sheetItem) { item in
switch item { switch item {
case let .showConditions(updated): case .showConditions:
UsageConditionsView( UsageConditionsView(
currUserServers: $ss.servers.currUserServers, currUserServers: $ss.servers.currUserServers,
userServers: $ss.servers.userServers, userServers: $ss.servers.userServers
updated: updated
) )
.modifier(ThemedBackground(grouped: true)) .modifier(ThemedBackground(grouped: true))
} }
@ -219,8 +218,7 @@ struct NetworkAndServers: View {
private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View { private func conditionsButton(_ conditionsAction: UsageConditionsAction) -> some View {
Button { Button {
let updated = if case .review = conditionsAction { true } else { false } sheetItem = .showConditions
sheetItem = .showConditions(updated: updated)
} label: { } label: {
switch conditionsAction { switch conditionsAction {
case .review: case .review:
@ -237,30 +235,26 @@ struct UsageConditionsView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@Binding var currUserServers: [UserOperatorServers] @Binding var currUserServers: [UserOperatorServers]
@Binding var userServers: [UserOperatorServers] @Binding var userServers: [UserOperatorServers]
var updated: Bool
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
HStack {
if updated {
Text("Updated conditions").font(.largeTitle).bold()
} else {
Text("Conditions of use").font(.largeTitle).bold()
Spacer()
conditionsLinkButton()
}
}
.padding(.top)
.padding(.top)
switch ChatModel.shared.conditions.conditionsAction { switch ChatModel.shared.conditions.conditionsAction {
case .none: case .none:
regularConditionsHeader()
.padding(.top)
.padding(.top)
ConditionsTextView() ConditionsTextView()
.padding(.bottom) .padding(.bottom)
.padding(.bottom) .padding(.bottom)
case let .review(operators, deadline, _): case let .review(operators, deadline, _):
HStack {
Text("Updated conditions").font(.largeTitle).bold()
}
.padding(.top)
.padding(.top)
Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") Text("Conditions will be accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
ConditionsTextView() ConditionsTextView()
VStack(spacing: 8) { VStack(spacing: 8) {
@ -272,10 +266,8 @@ struct UsageConditionsView: View {
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
.padding(.horizontal, 32) .padding(.horizontal, 32)
if updated {
conditionsDiffButton(.footnote) conditionsDiffButton(.footnote)
} } else {
} else if updated {
conditionsDiffButton() conditionsDiffButton()
.padding(.top) .padding(.top)
} }
@ -285,6 +277,9 @@ struct UsageConditionsView: View {
case let .accepted(operators): case let .accepted(operators):
regularConditionsHeader()
.padding(.top)
.padding(.top)
Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.") Text("Conditions are accepted for the operator(s): **\(operators.map { $0.legalName_ }.joined(separator: ", "))**.")
ConditionsTextView() ConditionsTextView()
.padding(.bottom) .padding(.bottom)
@ -340,6 +335,30 @@ struct UsageConditionsView: View {
} }
} }
private func regularConditionsHeader() -> some View {
HStack {
Text("Conditions of use").font(.largeTitle).bold()
Spacer()
conditionsLinkButton()
}
}
struct SimpleConditionsView: View {
var body: some View {
VStack(alignment: .leading, spacing: 20) {
regularConditionsHeader()
.padding(.top)
.padding(.top)
ConditionsTextView()
.padding(.bottom)
.padding(.bottom)
}
.padding(.horizontal, 25)
.frame(maxHeight: .infinity)
}
}
func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) { func validateServers_(_ userServers: Binding<[UserOperatorServers]>, _ serverErrors: Binding<[UserServersError]>) {
let userServersToValidate = userServers.wrappedValue let userServersToValidate = userServers.wrappedValue
Task { Task {

View file

@ -38,9 +38,9 @@ struct OperatorView: View {
.allowsHitTesting(!testing) .allowsHitTesting(!testing)
} }
@ViewBuilder private func operatorView() -> some View { private func operatorView() -> some View {
let duplicateHosts = findDuplicateHosts(serverErrors) let duplicateHosts = findDuplicateHosts(serverErrors)
VStack { return VStack {
List { List {
Section { Section {
infoViewLink() infoViewLink()
@ -500,14 +500,14 @@ struct SingleOperatorUsageConditionsView: View {
} }
} }
@ViewBuilder private func acceptConditionsButton() -> some View { private func acceptConditionsButton() -> some View {
let operatorIds = ChatModel.shared.conditions.serverOperators let operatorIds = ChatModel.shared.conditions.serverOperators
.filter { .filter {
$0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator $0.operatorId == userServers[operatorIndex].operator_.operatorId || // Opened operator
($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted ($0.enabled && !$0.conditionsAcceptance.conditionsAccepted) // Other enabled operators with conditions not accepted
} }
.map { $0.operatorId } .map { $0.operatorId }
Button { return Button {
acceptForOperators(operatorIds, operatorIndex) acceptForOperators(operatorIds, operatorIndex)
} label: { } label: {
Text("Accept conditions") Text("Accept conditions")

View file

@ -38,9 +38,9 @@ struct YourServersView: View {
.allowsHitTesting(!testing) .allowsHitTesting(!testing)
} }
@ViewBuilder private func yourServersView() -> some View { private func yourServersView() -> some View {
let duplicateHosts = findDuplicateHosts(serverErrors) let duplicateHosts = findDuplicateHosts(serverErrors)
List { return List {
if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty { if !userServers[operatorIndex].smpServers.filter({ !$0.deleted }).isEmpty {
Section { Section {
ForEach($userServers[operatorIndex].smpServers) { srv in ForEach($userServers[operatorIndex].smpServers) { srv in

View file

@ -14,12 +14,13 @@ struct PrivacySettings: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true @AppStorage(DEFAULT_PRIVACY_ACCEPT_IMAGES) private var autoAcceptImages = true
@AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true @AppStorage(DEFAULT_PRIVACY_LINK_PREVIEWS) private var useLinkPreviews = true
@State private var chatListOpenLinks = privacyChatListOpenLinksDefault.get()
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true @AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
@AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true @AppStorage(DEFAULT_PRIVACY_SAVE_LAST_DRAFT) private var saveLastDraft = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true @AppStorage(GROUP_DEFAULT_PRIVACY_ENCRYPT_LOCAL_FILES, store: groupDefaults) private var encryptLocalFiles = true
@AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true @AppStorage(GROUP_DEFAULT_PRIVACY_ASK_TO_APPROVE_RELAYS, store: groupDefaults) private var askToApproveRelays = true
@State private var simplexLinkMode = privacySimplexLinkModeDefault.get() @State private var simplexLinkMode = privacySimplexLinkModeDefault.get()
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_PRIVACY_SHORT_LINKS) private var shortSimplexLinks = false
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false @AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false @AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var currentLAMode = privacyLocalAuthModeDefault.get() @State private var currentLAMode = privacyLocalAuthModeDefault.get()
@ -75,17 +76,6 @@ struct PrivacySettings: View {
privacyLinkPreviewsGroupDefault.set(linkPreviews) privacyLinkPreviewsGroupDefault.set(linkPreviews)
} }
} }
settingsRow("arrow.up.right.circle", color: theme.colors.secondary) {
Picker("Open links from chat list", selection: $chatListOpenLinks) {
ForEach(PrivacyChatListOpenLinksMode.allCases) { mode in
Text(mode.text)
}
}
}
.frame(height: 36)
.onChange(of: chatListOpenLinks) { mode in
privacyChatListOpenLinksDefault.set(mode)
}
settingsRow("message", color: theme.colors.secondary) { settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews) Toggle("Show last messages", isOn: $showChatPreviews)
} }
@ -111,6 +101,11 @@ struct PrivacySettings: View {
.onChange(of: simplexLinkMode) { mode in .onChange(of: simplexLinkMode) { mode in
privacySimplexLinkModeDefault.set(mode) privacySimplexLinkModeDefault.set(mode)
} }
if developerTools {
settingsRow("link.badge.plus", color: theme.colors.secondary) {
Toggle("Use short links (BETA)", isOn: $shortSimplexLinks)
}
}
} header: { } header: {
Text("Chats") Text("Chats")
.foregroundColor(theme.colors.secondary) .foregroundColor(theme.colors.secondary)

View file

@ -29,10 +29,10 @@ let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents" let DEFAULT_CALL_KIT_CALLS_IN_RECENTS = "callKitCallsInRecents"
let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead let DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" // unused. Use GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES instead
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
let DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS = "privacyChatListOpenLinks"
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode" let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews" let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft" let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_SHORT_LINKS = "privacyShortLinks"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen" let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet" let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius" let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
@ -99,6 +99,7 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue, DEFAULT_PRIVACY_SIMPLEX_LINK_MODE: SimpleXLinkMode.description.rawValue,
DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true, DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS: true,
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true, DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_SHORT_LINKS: false,
DEFAULT_PRIVACY_PROTECT_SCREEN: false, DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false, DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0, DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
@ -183,8 +184,6 @@ let connectViaLinkTabDefault = EnumDefault<ConnectViaLinkTab>(defaults: UserDefa
let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) let privacySimplexLinkModeDefault = EnumDefault<SimpleXLinkMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description)
let privacyChatListOpenLinksDefault = EnumDefault<PrivacyChatListOpenLinksMode>(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_CHAT_LIST_OPEN_LINKS, withDefault: PrivacyChatListOpenLinksMode.ask)
let privacyLocalAuthModeDefault = EnumDefault<LAMode>(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) let privacyLocalAuthModeDefault = EnumDefault<LAMode>(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system)
let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET) let privacyDeliveryReceiptsSet = BoolDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET)
@ -281,9 +280,9 @@ struct SettingsView: View {
} }
} }
@ViewBuilder func settingsView() -> some View { func settingsView() -> some View {
let user = chatModel.currentUser
List { List {
let user = chatModel.currentUser
Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) { Section(header: Text("Settings").foregroundColor(theme.colors.secondary)) {
NavigationLink { NavigationLink {
NotificationsView() NotificationsView()
@ -528,7 +527,7 @@ struct ProfilePreview: View {
func profileName(_ profileOf: NamedChat) -> Text { func profileName(_ profileOf: NamedChat) -> Text {
var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2) var t = Text(profileOf.displayName).fontWeight(.semibold).font(.title2)
if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName { if profileOf.fullName != "" && profileOf.fullName != profileOf.displayName {
t = t + Text(" (" + profileOf.fullName + ")") t = t + Text(verbatim: " (" + profileOf.fullName + ")")
// .font(.callout) // .font(.callout)
} }
return t return t

View file

@ -33,7 +33,7 @@ struct StorageView: View {
private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View { private func directoryView(_ name: LocalizedStringKey, _ contents: [String: Int64]) -> some View {
Text(name).font(.headline) Text(name).font(.headline)
ForEach(Array(contents), id: \.key) { (key, value) in ForEach(Array(contents), id: \.key) { (key, value) in
Text(key).bold() + Text(" ") + Text("\(ByteCountFormatter.string(fromByteCount: value, countStyle: .binary))") Text(key).bold() + Text(verbatim: " ") + Text((ByteCountFormatter.string(fromByteCount: value, countStyle: .binary)))
} }
} }

View file

@ -8,7 +8,7 @@
import SwiftUI import SwiftUI
import MessageUI import MessageUI
import SimpleXChat @preconcurrency import SimpleXChat
struct UserAddressView: View { struct UserAddressView: View {
@Environment(\.dismiss) var dismiss: DismissAction @Environment(\.dismiss) var dismiss: DismissAction
@ -16,6 +16,7 @@ struct UserAddressView: View {
@EnvironmentObject var theme: AppTheme @EnvironmentObject var theme: AppTheme
@State var shareViaProfile = false @State var shareViaProfile = false
@State var autoCreate = false @State var autoCreate = false
@State private var showShortLink = true
@State private var aas = AutoAcceptState() @State private var aas = AutoAcceptState()
@State private var savedAAS = AutoAcceptState() @State private var savedAAS = AutoAcceptState()
@State private var showMailView = false @State private var showMailView = false
@ -135,8 +136,8 @@ struct UserAddressView: View {
@ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View { @ViewBuilder private func existingAddressView(_ userAddress: UserContactLink) -> some View {
Section { Section {
SimpleXLinkQRCode(uri: userAddress.connReqContact) SimpleXCreatedLinkQRCode(link: userAddress.connLinkContact, short: $showShortLink)
.id("simplex-contact-address-qrcode-\(userAddress.connReqContact)") .id("simplex-contact-address-qrcode-\(userAddress.connLinkContact.simplexChatUri(short: showShortLink))")
shareQRCodeButton(userAddress) shareQRCodeButton(userAddress)
// if MFMailComposeViewController.canSendMail() { // if MFMailComposeViewController.canSendMail() {
// shareViaEmailButton(userAddress) // shareViaEmailButton(userAddress)
@ -153,8 +154,7 @@ struct UserAddressView: View {
} }
addressSettingsButton(userAddress) addressSettingsButton(userAddress)
} header: { } header: {
Text("For social media") ToggleShortLinkHeader(text: Text("For social media"), link: userAddress.connLinkContact, short: $showShortLink)
.foregroundColor(theme.colors.secondary)
} footer: { } footer: {
if aas.business { if aas.business {
Text("Add your team members to the conversations.") Text("Add your team members to the conversations.")
@ -193,9 +193,10 @@ struct UserAddressView: View {
progressIndicator = true progressIndicator = true
Task { Task {
do { do {
let connReqContact = try await apiCreateUserAddress() let short = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_SHORT_LINKS)
let connLinkContact = try await apiCreateUserAddress(short: short)
DispatchQueue.main.async { DispatchQueue.main.async {
chatModel.userAddress = UserContactLink(connReqContact: connReqContact) chatModel.userAddress = UserContactLink(connLinkContact: connLinkContact)
alert = .shareOnCreate alert = .shareOnCreate
progressIndicator = false progressIndicator = false
} }
@ -231,7 +232,7 @@ struct UserAddressView: View {
private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View { private func shareQRCodeButton(_ userAddress: UserContactLink) -> some View {
Button { Button {
showShareSheet(items: [simplexChatLink(userAddress.connReqContact)]) showShareSheet(items: [simplexChatLink(userAddress.connLinkContact.simplexChatUri(short: showShortLink))])
} label: { } label: {
settingsRow("square.and.arrow.up", color: theme.colors.secondary) { settingsRow("square.and.arrow.up", color: theme.colors.secondary) {
Text("Share address") Text("Share address")
@ -294,6 +295,28 @@ struct UserAddressView: View {
} }
} }
struct ToggleShortLinkHeader: View {
@EnvironmentObject var theme: AppTheme
let text: Text
var link: CreatedConnLink
@Binding var short: Bool
var body: some View {
if link.connShortLink == nil {
text.foregroundColor(theme.colors.secondary)
} else {
HStack {
text.foregroundColor(theme.colors.secondary)
Spacer()
Text(short ? "Full link" : "Short link")
.textCase(.none)
.foregroundColor(theme.colors.primary)
.onTapGesture { short.toggle() }
}
}
}
}
private struct AutoAcceptState: Equatable { private struct AutoAcceptState: Equatable {
var enable = false var enable = false
var incognito = false var incognito = false
@ -542,7 +565,7 @@ private func saveAAS(_ aas: Binding<AutoAcceptState>, _ savedAAS: Binding<AutoAc
struct UserAddressView_Previews: PreviewProvider { struct UserAddressView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let chatModel = ChatModel() let chatModel = ChatModel()
chatModel.userAddress = UserContactLink(connReqContact: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D") chatModel.userAddress = UserContactLink(connLinkContact: CreatedConnLink(connFullLink: "https://simplex.chat/contact#/?v=1&smp=smp%3A%2F%2FPQUV2eL0t7OStZOoAsPEV2QYWt4-xilbakvGUGOItUo%3D%40smp6.simplex.im%2FK1rslx-m5bpXVIdMZg9NLUZ_8JBm8xTt%23MCowBQYDK2VuAyEALDeVe-sG8mRY22LsXlPgiwTNs9dbiLrNuA7f3ZMAJ2w%3D", connShortLink: nil))
return Group { return Group {

View file

@ -133,7 +133,6 @@ struct UserProfile: View {
.alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) } .alert(item: $alert) { a in userProfileAlert(a, $profile.displayName) }
} }
@ViewBuilder
private func overlayButton( private func overlayButton(
_ systemName: String, _ systemName: String,
edge: Edge.Set, edge: Edge.Set,

View file

@ -221,11 +221,11 @@ struct UserProfilesView: View {
!user.hidden ? nil : trimmedSearchTextOrPassword !user.hidden ? nil : trimmedSearchTextOrPassword
} }
@ViewBuilder private func profileActionView(_ action: UserProfileAction) -> some View { private func profileActionView(_ action: UserProfileAction) -> some View {
let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces) let passwordValid = actionPassword == actionPassword.trimmingCharacters(in: .whitespaces)
let passwordField = PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid) let passwordField = PassphraseField(key: $actionPassword, placeholder: "Profile password", valid: passwordValid)
let actionEnabled: (User) -> Bool = { user in actionPassword != "" && passwordValid && correctPassword(user, actionPassword) } let actionEnabled: (User) -> Bool = { user in actionPassword != "" && passwordValid && correctPassword(user, actionPassword) }
List { return List {
switch action { switch action {
case let .deleteUser(user, delSMPQueues): case let .deleteUser(user, delSMPQueues):
actionHeader("Delete profile", user) actionHeader("Delete profile", user)

View file

@ -9,6 +9,10 @@
<string>applinks:simplex.chat</string> <string>applinks:simplex.chat</string>
<string>applinks:www.simplex.chat</string> <string>applinks:www.simplex.chat</string>
<string>applinks:simplex.chat?mode=developer</string> <string>applinks:simplex.chat?mode=developer</string>
<string>applinks:*.simplex.im</string>
<string>applinks:*.simplex.im?mode=developer</string>
<string>applinks:*.simplexonflux.com</string>
<string>applinks:*.simplexonflux.com?mode=developer</string>
</array> </array>
<key>com.apple.security.application-groups</key> <key>com.apple.security.application-groups</key>
<array> <array>

View file

@ -2,25 +2,9 @@
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd"> <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 http://docs.oasis-open.org/xliff/v1.2/os/xliff-core-1.2-strict.xsd">
<file original="en.lproj/Localizable.strings" source-language="en" target-language="bg" datatype="plaintext"> <file original="en.lproj/Localizable.strings" source-language="en" target-language="bg" datatype="plaintext">
<header> <header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="16.2" build-num="16C5032a"/>
</header> </header>
<body> <body>
<trans-unit id="&#10;" xml:space="preserve">
<source>
</source>
<target>
</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=" " xml:space="preserve">
<source> </source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=" (" xml:space="preserve">
<source> (</source>
<target> (</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id=" (can be copied)" xml:space="preserve"> <trans-unit id=" (can be copied)" xml:space="preserve">
<source> (can be copied)</source> <source> (can be copied)</source>
<target> (може да се копира)</target> <target> (може да се копира)</target>
@ -323,11 +307,6 @@
<target>%u пропуснати съобщения.</target> <target>%u пропуснати съобщения.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="(" xml:space="preserve">
<source>(</source>
<target>(</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="(new)" xml:space="preserve"> <trans-unit id="(new)" xml:space="preserve">
<source>(new)</source> <source>(new)</source>
<target>(ново)</target> <target>(ново)</target>
@ -338,11 +317,6 @@
<target>(това устройство v%@)</target> <target>(това устройство v%@)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id=")" xml:space="preserve">
<source>)</source>
<target>)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="**Create 1-time link**: to create and share a new invitation link." xml:space="preserve"> <trans-unit id="**Create 1-time link**: to create and share a new invitation link." xml:space="preserve">
<source>**Create 1-time link**: to create and share a new invitation link.</source> <source>**Create 1-time link**: to create and share a new invitation link.</source>
<target>**Добави контакт**: за създаване на нов линк.</target> <target>**Добави контакт**: за създаване на нов линк.</target>
@ -407,11 +381,6 @@
<target>\*удебелен*</target> <target>\*удебелен*</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id=", " xml:space="preserve">
<source>, </source>
<target>, </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="- connect to [directory service](simplex:/contact#/?v=1-4&amp;smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!&#10;- delivery receipts (up to 20 members).&#10;- faster and more stable." xml:space="preserve"> <trans-unit id="- connect to [directory service](simplex:/contact#/?v=1-4&amp;smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!&#10;- delivery receipts (up to 20 members).&#10;- faster and more stable." xml:space="preserve">
<source>- connect to [directory service](simplex:/contact#/?v=1-4&amp;smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)! <source>- connect to [directory service](simplex:/contact#/?v=1-4&amp;smp=smp%3A%2F%2Fu2dS9sG8nMNURyZwqASV4yROM28Er0luVTx5X1CsMrU%3D%40smp4.simplex.im%2FeXSPwqTkKyDO3px4fLf1wx3MvPdjdLW3%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAaiv6MkMH44L2TcYrt_CsX3ZvM11WgbMEUn0hkIKTOho%253D%26srv%3Do5vmywmrnaxalvz6wi3zicyftgio6psuvyniis6gco6bp6ekl4cqj4id.onion) (BETA)!
- delivery receipts (up to 20 members). - delivery receipts (up to 20 members).
@ -448,11 +417,6 @@
- история на редактиране.</target> - история на редактиране.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="." xml:space="preserve">
<source>.</source>
<target>.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="0 sec" xml:space="preserve"> <trans-unit id="0 sec" xml:space="preserve">
<source>0 sec</source> <source>0 sec</source>
<target>0 сек</target> <target>0 сек</target>
@ -519,11 +483,6 @@
<target>30 секунди</target> <target>30 секунди</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id=": " xml:space="preserve">
<source>: </source>
<target>: </target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="&lt;p&gt;Hi!&lt;/p&gt;&#10;&lt;p&gt;&lt;a href=&quot;%@&quot;&gt;Connect to me via SimpleX Chat&lt;/a&gt;&lt;/p&gt;" xml:space="preserve"> <trans-unit id="&lt;p&gt;Hi!&lt;/p&gt;&#10;&lt;p&gt;&lt;a href=&quot;%@&quot;&gt;Connect to me via SimpleX Chat&lt;/a&gt;&lt;/p&gt;" xml:space="preserve">
<source>&lt;p&gt;Hi!&lt;/p&gt; <source>&lt;p&gt;Hi!&lt;/p&gt;
&lt;p&gt;&lt;a href="%@"&gt;Connect to me via SimpleX Chat&lt;/a&gt;&lt;/p&gt;</source> &lt;p&gt;&lt;a href="%@"&gt;Connect to me via SimpleX Chat&lt;/a&gt;&lt;/p&gt;</source>
@ -531,14 +490,6 @@
&lt;p&gt;&lt;a href="%@"&gt;Свържете се с мен чрез SimpleX Chat&lt;/a&gt;&lt;/p&gt;</target> &lt;p&gt;&lt;a href="%@"&gt;Свържете се с мен чрез SimpleX Chat&lt;/a&gt;&lt;/p&gt;</target>
<note>email text</note> <note>email text</note>
</trans-unit> </trans-unit>
<trans-unit id="@%@" xml:space="preserve">
<source>@%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="@'%@'" xml:space="preserve">
<source>@'%@'</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="A few more things" xml:space="preserve"> <trans-unit id="A few more things" xml:space="preserve">
<source>A few more things</source> <source>A few more things</source>
<target>Още няколко неща</target> <target>Още няколко неща</target>
@ -780,8 +731,8 @@
<target>Всички чатове и съобщения ще бъдат изтрити - това не може да бъде отменено!</target> <target>Всички чатове и съобщения ще бъдат изтрити - това не може да бъде отменено!</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="All chats will be removed from the list (text), and the list deleted." xml:space="preserve"> <trans-unit id="All chats will be removed from the list %@, and the list deleted." xml:space="preserve">
<source>All chats will be removed from the list (text), and the list deleted.</source> <source>All chats will be removed from the list %@, and the list deleted.</source>
<note>alert message</note> <note>alert message</note>
</trans-unit> </trans-unit>
<trans-unit id="All data is erased when it is entered." xml:space="preserve"> <trans-unit id="All data is erased when it is entered." xml:space="preserve">
@ -827,6 +778,10 @@
<source>All reports will be archived for you.</source> <source>All reports will be archived for you.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="All servers" xml:space="preserve">
<source>All servers</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected." xml:space="preserve"> <trans-unit id="All your contacts will remain connected." xml:space="preserve">
<source>All your contacts will remain connected.</source> <source>All your contacts will remain connected.</source>
<target>Всички ваши контакти ще останат свързани.</target> <target>Всички ваши контакти ще останат свързани.</target>
@ -1108,10 +1063,6 @@
<target>Архивиране на база данни</target> <target>Архивиране на база данни</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Ask" xml:space="preserve">
<source>Ask</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Attach" xml:space="preserve"> <trans-unit id="Attach" xml:space="preserve">
<source>Attach</source> <source>Attach</source>
<target>Прикачи</target> <target>Прикачи</target>
@ -1349,6 +1300,12 @@
<target>Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА).</target> <target>Чрез чат профил (по подразбиране) или [чрез връзка](https://simplex.chat/blog/20230204-simplex-chat-v4-5-user-chat-profiles.html#transport-isolation) (БЕТА).</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="By using SimpleX Chat you agree to:&#10;- send only legal content in public groups.&#10;- respect other users no spam." xml:space="preserve">
<source>By using SimpleX Chat you agree to:
- send only legal content in public groups.
- respect other users no spam.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Call already ended!" xml:space="preserve"> <trans-unit id="Call already ended!" xml:space="preserve">
<source>Call already ended!</source> <source>Call already ended!</source>
<target>Разговорът вече приключи!</target> <target>Разговорът вече приключи!</target>
@ -1703,14 +1660,6 @@
<source>Conditions of use</source> <source>Conditions of use</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Conditions will be accepted for enabled operators after 30 days." xml:space="preserve">
<source>Conditions will be accepted for enabled operators after 30 days.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Conditions will be accepted for operator(s): **%@**." xml:space="preserve">
<source>Conditions will be accepted for operator(s): **%@**.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Conditions will be accepted for the operator(s): **%@**." xml:space="preserve"> <trans-unit id="Conditions will be accepted for the operator(s): **%@**." xml:space="preserve">
<source>Conditions will be accepted for the operator(s): **%@**.</source> <source>Conditions will be accepted for the operator(s): **%@**.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@ -1728,6 +1677,10 @@
<target>Конфигурирай ICE сървъри</target> <target>Конфигурирай ICE сървъри</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Configure server operators" xml:space="preserve">
<source>Configure server operators</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Confirm" xml:space="preserve"> <trans-unit id="Confirm" xml:space="preserve">
<source>Confirm</source> <source>Confirm</source>
<target>Потвърди</target> <target>Потвърди</target>
@ -2832,8 +2785,8 @@ This is your own one-time link!</source>
<target>Активиране (запазване на промените)</target> <target>Активиране (запазване на промените)</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Enable Flux" xml:space="preserve"> <trans-unit id="Enable Flux in Network &amp; servers settings for better metadata privacy." xml:space="preserve">
<source>Enable Flux</source> <source>Enable Flux in Network &amp; servers settings for better metadata privacy.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Enable SimpleX Lock" xml:space="preserve"> <trans-unit id="Enable SimpleX Lock" xml:space="preserve">
@ -3386,7 +3339,9 @@ This is your own one-time link!</source>
<trans-unit id="Error: %@" xml:space="preserve"> <trans-unit id="Error: %@" xml:space="preserve">
<source>Error: %@</source> <source>Error: %@</source>
<target>Грешка: %@</target> <target>Грешка: %@</target>
<note>alert message</note> <note>alert message
file error text
snd error text</note>
</trans-unit> </trans-unit>
<trans-unit id="Error: URL is invalid" xml:space="preserve"> <trans-unit id="Error: URL is invalid" xml:space="preserve">
<source>Error: URL is invalid</source> <source>Error: URL is invalid</source>
@ -3495,9 +3450,9 @@ This is your own one-time link!</source>
%@</source> %@</source>
<note>alert message</note> <note>alert message</note>
</trans-unit> </trans-unit>
<trans-unit id="File is blocked by server operator:&#10;(info.reason.text)." xml:space="preserve"> <trans-unit id="File is blocked by server operator:&#10;%@." xml:space="preserve">
<source>File is blocked by server operator: <source>File is blocked by server operator:
(info.reason.text).</source> %@.</source>
<note>file error text</note> <note>file error text</note>
</trans-unit> </trans-unit>
<trans-unit id="File not found - most likely file was deleted or cancelled." xml:space="preserve"> <trans-unit id="File not found - most likely file was deleted or cancelled." xml:space="preserve">
@ -5305,7 +5260,7 @@ Requires compatible VPN.</source>
<trans-unit id="Open" xml:space="preserve"> <trans-unit id="Open" xml:space="preserve">
<source>Open</source> <source>Open</source>
<target>Отвори</target> <target>Отвори</target>
<note>No comment provided by engineer.</note> <note>alert action</note>
</trans-unit> </trans-unit>
<trans-unit id="Open Settings" xml:space="preserve"> <trans-unit id="Open Settings" xml:space="preserve">
<source>Open Settings</source> <source>Open Settings</source>
@ -5335,23 +5290,15 @@ Requires compatible VPN.</source>
<target>Отвори група</target> <target>Отвори група</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Open link" xml:space="preserve"> <trans-unit id="Open link?" xml:space="preserve">
<source>Open link</source> <source>Open link?</source>
<note>No comment provided by engineer.</note> <note>alert title</note>
</trans-unit>
<trans-unit id="Open links from chat list" xml:space="preserve">
<source>Open links from chat list</source>
<note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Open migration to another device" xml:space="preserve"> <trans-unit id="Open migration to another device" xml:space="preserve">
<source>Open migration to another device</source> <source>Open migration to another device</source>
<target>Отвори миграцията към друго устройство</target> <target>Отвори миграцията към друго устройство</target>
<note>authentication reason</note> <note>authentication reason</note>
</trans-unit> </trans-unit>
<trans-unit id="Open web link?" xml:space="preserve">
<source>Open web link?</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Opening app…" xml:space="preserve"> <trans-unit id="Opening app…" xml:space="preserve">
<source>Opening app…</source> <source>Opening app…</source>
<target>Приложението се отваря…</target> <target>Приложението се отваря…</target>
@ -5638,11 +5585,19 @@ Error: %@</source>
<source>Privacy for your customers.</source> <source>Privacy for your customers.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Privacy policy and conditions of use." xml:space="preserve">
<source>Privacy policy and conditions of use.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Privacy redefined" xml:space="preserve"> <trans-unit id="Privacy redefined" xml:space="preserve">
<source>Privacy redefined</source> <source>Privacy redefined</source>
<target>Поверителността преосмислена</target> <target>Поверителността преосмислена</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Private chats, groups and your contacts are not accessible to server operators." xml:space="preserve">
<source>Private chats, groups and your contacts are not accessible to server operators.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Private filenames" xml:space="preserve"> <trans-unit id="Private filenames" xml:space="preserve">
<source>Private filenames</source> <source>Private filenames</source>
<target>Поверителни имена на файлове</target> <target>Поверителни имена на файлове</target>
@ -6106,8 +6061,8 @@ Enable in *Network &amp; servers* settings.</source>
<source>Report violation: only group moderators will see it.</source> <source>Report violation: only group moderators will see it.</source>
<note>report reason</note> <note>report reason</note>
</trans-unit> </trans-unit>
<trans-unit id="Report: (text.isEmpty ? reason.text : text)" xml:space="preserve"> <trans-unit id="Report: %@" xml:space="preserve">
<source>Report: (text.isEmpty ? reason.text : text)</source> <source>Report: %@</source>
<note>report in notification</note> <note>report in notification</note>
</trans-unit> </trans-unit>
<trans-unit id="Reporting messages to moderators is prohibited." xml:space="preserve"> <trans-unit id="Reporting messages to moderators is prohibited." xml:space="preserve">
@ -6202,10 +6157,6 @@ Enable in *Network &amp; servers* settings.</source>
<source>Review conditions</source> <source>Review conditions</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Review later" xml:space="preserve">
<source>Review later</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Revoke" xml:space="preserve"> <trans-unit id="Revoke" xml:space="preserve">
<source>Revoke</source> <source>Revoke</source>
<target>Отзови</target> <target>Отзови</target>
@ -6841,6 +6792,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Сподели с контактите</target> <target>Сподели с контактите</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Short link" xml:space="preserve">
<source>Short link</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show QR code" xml:space="preserve"> <trans-unit id="Show QR code" xml:space="preserve">
<source>Show QR code</source> <source>Show QR code</source>
<target>Покажи QR код</target> <target>Покажи QR код</target>
@ -6934,6 +6889,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>SimpleX address or 1-time link?</source> <source>SimpleX address or 1-time link?</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="SimpleX channel link" xml:space="preserve">
<source>SimpleX channel link</source>
<note>simplex link type</note>
</trans-unit>
<trans-unit id="SimpleX contact address" xml:space="preserve"> <trans-unit id="SimpleX contact address" xml:space="preserve">
<source>SimpleX contact address</source> <source>SimpleX contact address</source>
<target>SimpleX адрес за контакт</target> <target>SimpleX адрес за контакт</target>
@ -7370,19 +7329,10 @@ It can happen because of some bug or when the connection is compromised.</source
<target>Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита.</target> <target>Старата база данни не бе премахната по време на миграцията, тя може да бъде изтрита.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="The profile is only shared with your contacts." xml:space="preserve">
<source>The profile is only shared with your contacts.</source>
<target>Профилът се споделя само с вашите контакти.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The same conditions will apply to operator **%@**." xml:space="preserve"> <trans-unit id="The same conditions will apply to operator **%@**." xml:space="preserve">
<source>The same conditions will apply to operator **%@**.</source> <source>The same conditions will apply to operator **%@**.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="The same conditions will apply to operator(s): **%@**." xml:space="preserve">
<source>The same conditions will apply to operator(s): **%@**.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="The second preset operator in the app!" xml:space="preserve"> <trans-unit id="The second preset operator in the app!" xml:space="preserve">
<source>The second preset operator in the app!</source> <source>The second preset operator in the app!</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@ -7492,6 +7442,10 @@ It can happen because of some bug or when the connection is compromised.</source
<target>Това е вашят еднократен линк за връзка!</target> <target>Това е вашят еднократен линк за връзка!</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link." xml:space="preserve">
<source>This link requires a newer app version. Please upgrade the app or ask your contact to send a compatible link.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="This link was used with another mobile device, please create a new link on the desktop." xml:space="preserve"> <trans-unit id="This link was used with another mobile device, please create a new link on the desktop." xml:space="preserve">
<source>This link was used with another mobile device, please create a new link on the desktop.</source> <source>This link was used with another mobile device, please create a new link on the desktop.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@ -7774,6 +7728,10 @@ To connect, please ask your contact to create another connection link and check
<target>Непрочетено</target> <target>Непрочетено</target>
<note>swipe action</note> <note>swipe action</note>
</trans-unit> </trans-unit>
<trans-unit id="Unsupported connection link" xml:space="preserve">
<source>Unsupported connection link</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Up to 100 last messages are sent to new members." xml:space="preserve"> <trans-unit id="Up to 100 last messages are sent to new members." xml:space="preserve">
<source>Up to 100 last messages are sent to new members.</source> <source>Up to 100 last messages are sent to new members.</source>
<target>На новите членове се изпращат до последните 100 съобщения.</target> <target>На новите членове се изпращат до последните 100 съобщения.</target>
@ -7861,6 +7819,10 @@ To connect, please ask your contact to create another connection link and check
<source>Use TCP port %@ when no port is specified.</source> <source>Use TCP port %@ when no port is specified.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Use TCP port 443 for preset servers only." xml:space="preserve">
<source>Use TCP port 443 for preset servers only.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Use chat" xml:space="preserve"> <trans-unit id="Use chat" xml:space="preserve">
<source>Use chat</source> <source>Use chat</source>
<target>Използвай чата</target> <target>Използвай чата</target>
@ -7921,6 +7883,10 @@ To connect, please ask your contact to create another connection link and check
<source>Use servers</source> <source>Use servers</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Use short links (BETA)" xml:space="preserve">
<source>Use short links (BETA)</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Use the app while in the call." xml:space="preserve"> <trans-unit id="Use the app while in the call." xml:space="preserve">
<source>Use the app while in the call.</source> <source>Use the app while in the call.</source>
<target>Използвайте приложението по време на разговора.</target> <target>Използвайте приложението по време на разговора.</target>
@ -8202,10 +8168,6 @@ To connect, please ask your contact to create another connection link and check
<source>XFTP server</source> <source>XFTP server</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Yes" xml:space="preserve">
<source>Yes</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You **must not** use the same database on two devices." xml:space="preserve"> <trans-unit id="You **must not** use the same database on two devices." xml:space="preserve">
<source>You **must not** use the same database on two devices.</source> <source>You **must not** use the same database on two devices.</source>
<target>**Не трябва** да използвате една и съща база данни на две устройства.</target> <target>**Не трябва** да използвате една и съща база данни на две устройства.</target>
@ -8295,10 +8257,6 @@ Repeat join request?</source>
<source>You can change it in Appearance settings.</source> <source>You can change it in Appearance settings.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="You can configure operators in Network &amp; servers settings." xml:space="preserve">
<source>You can configure operators in Network &amp; servers settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can configure servers via settings." xml:space="preserve"> <trans-unit id="You can configure servers via settings." xml:space="preserve">
<source>You can configure servers via settings.</source> <source>You can configure servers via settings.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
@ -8532,11 +8490,6 @@ Repeat connection request?</source>
<target>Вашите ICE сървъри</target> <target>Вашите ICE сървъри</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your SMP servers" xml:space="preserve">
<source>Your SMP servers</source>
<target>Вашите SMP сървъри</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your SimpleX address" xml:space="preserve"> <trans-unit id="Your SimpleX address" xml:space="preserve">
<source>Your SimpleX address</source> <source>Your SimpleX address</source>
<target>Вашият адрес в SimpleX</target> <target>Вашият адрес в SimpleX</target>
@ -8619,6 +8572,11 @@ Repeat connection request?</source>
<target>Вашият профил **%@** ще бъде споделен.</target> <target>Вашият профил **%@** ще бъде споделен.</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="Your profile is stored on your device and only shared with your contacts." xml:space="preserve">
<source>Your profile is stored on your device and only shared with your contacts.</source>
<target>Профилът се споделя само с вашите контакти.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." xml:space="preserve"> <trans-unit id="Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile." xml:space="preserve">
<source>Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.</source> <source>Your profile is stored on your device and shared only with your contacts. SimpleX servers cannot see your profile.</source>
<target>Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил.</target> <target>Вашият профил се съхранява на вашето устройство и се споделя само с вашите контакти. SimpleX сървърите не могат да видят вашия профил.</target>
@ -8628,11 +8586,6 @@ Repeat connection request?</source>
<source>Your profile was changed. If you save it, the updated profile will be sent to all your contacts.</source> <source>Your profile was changed. If you save it, the updated profile will be sent to all your contacts.</source>
<note>alert message</note> <note>alert message</note>
</trans-unit> </trans-unit>
<trans-unit id="Your profile, contacts and delivered messages are stored on your device." xml:space="preserve">
<source>Your profile, contacts and delivered messages are stored on your device.</source>
<target>Вашият профил, контакти и доставени съобщения се съхраняват на вашето устройство.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Your random profile" xml:space="preserve"> <trans-unit id="Your random profile" xml:space="preserve">
<source>Your random profile</source> <source>Your random profile</source>
<target>Вашият автоматично генериран профил</target> <target>Вашият автоматично генериран профил</target>
@ -8767,7 +8720,8 @@ Repeat connection request?</source>
<trans-unit id="blocked by admin" xml:space="preserve"> <trans-unit id="blocked by admin" xml:space="preserve">
<source>blocked by admin</source> <source>blocked by admin</source>
<target>блокиран от админ</target> <target>блокиран от админ</target>
<note>marked deleted chat item preview text</note> <note>blocked chat item
marked deleted chat item preview text</note>
</trans-unit> </trans-unit>
<trans-unit id="bold" xml:space="preserve"> <trans-unit id="bold" xml:space="preserve">
<source>bold</source> <source>bold</source>
@ -9062,19 +9016,10 @@ Repeat connection request?</source>
<target>грешка</target> <target>грешка</target>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="event happened" xml:space="preserve">
<source>event happened</source>
<target>събитие се случи</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="expired" xml:space="preserve"> <trans-unit id="expired" xml:space="preserve">
<source>expired</source> <source>expired</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="for better metadata privacy." xml:space="preserve">
<source>for better metadata privacy.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="forwarded" xml:space="preserve"> <trans-unit id="forwarded" xml:space="preserve">
<source>forwarded</source> <source>forwarded</source>
<target>препратено</target> <target>препратено</target>
@ -9637,7 +9582,7 @@ last received msg: %2$@</source>
</file> </file>
<file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="bg" datatype="plaintext"> <file original="en.lproj/SimpleX--iOS--InfoPlist.strings" source-language="en" target-language="bg" datatype="plaintext">
<header> <header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="16.2" build-num="16C5032a"/>
</header> </header>
<body> <body>
<trans-unit id="CFBundleName" xml:space="preserve"> <trans-unit id="CFBundleName" xml:space="preserve">
@ -9674,7 +9619,7 @@ last received msg: %2$@</source>
</file> </file>
<file original="SimpleX NSE/en.lproj/InfoPlist.strings" source-language="en" target-language="bg" datatype="plaintext"> <file original="SimpleX NSE/en.lproj/InfoPlist.strings" source-language="en" target-language="bg" datatype="plaintext">
<header> <header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="16.2" build-num="16C5032a"/>
</header> </header>
<body> <body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve"> <trans-unit id="CFBundleDisplayName" xml:space="preserve">
@ -9696,13 +9641,17 @@ last received msg: %2$@</source>
</file> </file>
<file original="SimpleX NSE/en.lproj/Localizable.strings" source-language="en" target-language="bg" datatype="plaintext"> <file original="SimpleX NSE/en.lproj/Localizable.strings" source-language="en" target-language="bg" datatype="plaintext">
<header> <header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="16.2" build-num="16C5032a"/>
</header> </header>
<body> <body>
<trans-unit id="%d new events" xml:space="preserve"> <trans-unit id="%d new events" xml:space="preserve">
<source>%d new events</source> <source>%d new events</source>
<note>notification body</note> <note>notification body</note>
</trans-unit> </trans-unit>
<trans-unit id="From %d chat(s)" xml:space="preserve">
<source>From %d chat(s)</source>
<note>notification body</note>
</trans-unit>
<trans-unit id="From: %@" xml:space="preserve"> <trans-unit id="From: %@" xml:space="preserve">
<source>From: %@</source> <source>From: %@</source>
<note>notification body</note> <note>notification body</note>
@ -9715,15 +9664,11 @@ last received msg: %2$@</source>
<source>New messages</source> <source>New messages</source>
<note>notification</note> <note>notification</note>
</trans-unit> </trans-unit>
<trans-unit id="New messages in %d chats" xml:space="preserve">
<source>New messages in %d chats</source>
<note>notification body</note>
</trans-unit>
</body> </body>
</file> </file>
<file original="SimpleX SE/en.lproj/InfoPlist.strings" source-language="en" target-language="bg" datatype="plaintext"> <file original="SimpleX SE/en.lproj/InfoPlist.strings" source-language="en" target-language="bg" datatype="plaintext">
<header> <header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="16.2" build-num="16C5032a"/>
</header> </header>
<body> <body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve"> <trans-unit id="CFBundleDisplayName" xml:space="preserve">
@ -9742,7 +9687,7 @@ last received msg: %2$@</source>
</file> </file>
<file original="SimpleX SE/en.lproj/Localizable.strings" source-language="en" target-language="bg" datatype="plaintext"> <file original="SimpleX SE/en.lproj/Localizable.strings" source-language="en" target-language="bg" datatype="plaintext">
<header> <header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/> <tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="16.2" build-num="16C5032a"/>
</header> </header>
<body> <body>
<trans-unit id="%@" xml:space="preserve"> <trans-unit id="%@" xml:space="preserve">

View file

@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj", "project" : "SimpleX.xcodeproj",
"targetLocale" : "bg", "targetLocale" : "bg",
"toolInfo" : { "toolInfo" : {
"toolBuildNumber" : "15F31d", "toolBuildNumber" : "16C5032a",
"toolID" : "com.apple.dt.xcode", "toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode", "toolName" : "Xcode",
"toolVersion" : "15.4" "toolVersion" : "16.2"
}, },
"version" : "1.0" "version" : "1.0"
} }

View file

@ -3422,8 +3422,8 @@ It can happen because of some bug or when the connection is compromised.</source
<source>The old database was not removed during the migration, it can be deleted.</source> <source>The old database was not removed during the migration, it can be deleted.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="The profile is only shared with your contacts." xml:space="preserve"> <trans-unit id="Your profile is stored on your device and only shared with your contacts." xml:space="preserve">
<source>The profile is only shared with your contacts.</source> <source>Your profile is stored on your device and only shared with your contacts.</source>
<note>No comment provided by engineer.</note> <note>No comment provided by engineer.</note>
</trans-unit> </trans-unit>
<trans-unit id="The sender will NOT be notified" xml:space="preserve"> <trans-unit id="The sender will NOT be notified" xml:space="preserve">

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