Compare commits

...

53 Commits

Author SHA1 Message Date
Avently 56b9d73234 adapt code 2024-08-01 17:22:21 +09:00
Avently acba0b4a7d android, desktop: chats context enhancements 2024-08-01 17:09:26 +09:00
spaced4ndy 95de471754 desktop: fix app without user crashing when trying to get subscription data (#4551) 2024-08-01 08:05:35 +01:00
Arturs Krumins cbc86cd81e ios: disable chats in share-sheet based on preferences (#4549)
* claenup

* cleanup

* remove groupFeatureEnabled from Chat

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-08-01 07:48:17 +01:00
Stanislav Dmitrenko 19cab39ee8 android, desktop: refactoring to use mutex when updating chats (#4541)
* moving to mutablestate + snapshotstatelist from snapshotstatelist

* android, desktop: refactoring to use mutex when updating chats

* wrapped into class instead of object

* fix

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-31 18:43:31 +01:00
Arturs Krumins 6fa3695ad6 ios: add database password prompt to share-sheet, fix sharing screenshot (#4546)
* ios: add password prompt to share-sheet

* fix sharing screenshots

* s/Password/Passphrase/

* alert title

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-31 18:07:11 +01:00
Stanislav Dmitrenko 6e6afdbd25 ios: multiple messages deletion (#4535)
* ios: multiple messages deletion

* changes

* layout

* fix

* changes in design and UX

* fixes

* padding

* paddings

* refactor

* changes

* gray circles, separator, optimize

* titles

* disable moderation for own single message

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-31 15:00:14 +01:00
Evgeny Poberezkin 93e88c3953 ios: optionally lock share extension when SimpleX Lock is enabled (default is to lock), allow link previews if enabled in the app, use the same shape of avatars (#4547) 2024-07-31 13:54:47 +01:00
Evgeny Poberezkin 676b533393 ui: translations (#4540)
* Translated using Weblate (Hungarian)

Currently translated at 99.7% (1948 of 1953 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 99.7% (1948 of 1953 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% (1953 of 1953 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% (1687 of 1687 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (1953 of 1953 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% (1687 of 1687 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1953 of 1953 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% (1953 of 1953 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% (1687 of 1687 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 3.8% (75 of 1953 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 99.6% (1947 of 1953 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 100.0% (1953 of 1953 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1953 of 1953 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% (1687 of 1687 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% (1953 of 1953 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 94.2% (1590 of 1687 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 83.5% (1631 of 1953 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 4.4% (86 of 1953 strings)

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

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

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

* Translated using Weblate (French)

Currently translated at 99.7% (1948 of 1953 strings)

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

* Translated using Weblate (French)

Currently translated at 99.5% (1679 of 1687 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 96.0% (1875 of 1953 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 94.1% (1589 of 1687 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 100.0% (1953 of 1953 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% (1687 of 1687 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (1953 of 1953 strings)

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

* Translated using Weblate (Vietnamese)

Currently translated at 25.4% (498 of 1953 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1687 of 1687 strings)

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

* Translated using Weblate (French)

Currently translated at 99.5% (1680 of 1687 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 99.5% (1945 of 1953 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 98.3% (1659 of 1687 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 98.3% (1659 of 1687 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 4.6% (90 of 1953 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 99.5% (1944 of 1952 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% (1685 of 1687 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (1958 of 1958 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% (1952 of 1952 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% (1687 of 1687 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 26.0% (509 of 1952 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1958 of 1958 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1958 of 1958 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 100.0% (1952 of 1952 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% (1687 of 1687 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 100.0% (1958 of 1958 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% (1952 of 1952 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% (1687 of 1687 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 26.6% (521 of 1952 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% (1952 of 1952 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% (1687 of 1687 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1952 of 1952 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1687 of 1687 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 100.0% (1952 of 1952 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% (1687 of 1687 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 5.2% (103 of 1952 strings)

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

* Translated using Weblate (Polish)

Currently translated at 96.6% (1893 of 1958 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (1952 of 1952 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% (1687 of 1687 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 27.8% (543 of 1952 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 5.2% (103 of 1952 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1963 of 1963 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 100.0% (1963 of 1963 strings)

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

* Translated using Weblate (Polish)

Currently translated at 99.9% (1957 of 1958 strings)

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

* Translated using Weblate (Polish)

Currently translated at 100.0% (1687 of 1687 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 5.3% (104 of 1952 strings)

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

* Translated using Weblate (Italian)

Currently translated at 100.0% (1963 of 1963 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1963 of 1963 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% (1963 of 1963 strings)

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

* update multiplatform strings

* ios: import localizations

* export localizations, add localized strings to extensions

---------

Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: billy appetie <billy_appetie@users.noreply.hosted.weblate.org>
Co-authored-by: M1K4 <oomikaoo@gmail.com>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Diego Luiz <diegoluizfps@gmail.com>
Co-authored-by: Reza Almanda <rezaalmanda27@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Mqlx <vncrlecgubhp@rel.eliott.cc>
Co-authored-by: No name <CertainBot@users.noreply.hosted.weblate.org>
Co-authored-by: khalidbelk <khalid.belkassmi-el-hafi@epitech.eu>
Co-authored-by: tuananh-ng <158744840+tuananh-ng@users.noreply.github.com>
Co-authored-by: Ophiushi <41908476+ishi-sama@users.noreply.github.com>
Co-authored-by: B.O.S.S <BxOxSxS@protonmail.com>
2024-07-31 08:54:03 +01:00
Stanislav Dmitrenko e1fb0ac0b1 ui: delete multiple messages (#4532)
* ui: delete multiple messages

* ios

* simplify, rename

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-30 23:56:56 +01:00
Evgeny Poberezkin 8bda64a5c1 core: api to delete multiple messages (#4452)
* core: api proposal (not implemeted) to delete multiple messages

* core: batch delete multiple messages; allow to moderate self (#4513)

* allow to moderate self, remove saving item-message record on mark delete

* direct batched

* local batched

* group batched

* moderate batched

* refactor

* fix

* fix test

* remove unused event

* direct message batching wip

* direct test

* more tests

* trunk

* batch compressed

* remove unused function

* new agent api

* sendGroupMessages

* forward batched

* refactor

* remove comment

* rename, comment

* refactor

* many chat batches test (doesn't pass)

* refactor

* comment

* rename

* comment

* linearize

* fix

* fix

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* core: check item deletable with margin (#4533)

* simplexmq

* remove L.singleton (ghc 8.10.7)

* test delay

---------

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>
2024-07-30 22:59:47 +01:00
spaced4ndy ab058d7222 core: add SimpleX-Status preset contact card; create contact cards for each new user (#4544) 2024-07-30 20:00:51 +04:00
Evgeny Poberezkin 4f9c53f561 6.0-beta.2: ios 229, android 227, desktop 58 2024-07-30 08:14:22 +01:00
Stanislav Dmitrenko 5257c6f9ca android, desktop: fix crash on fast clicking every chat in list (#4536)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-30 07:33:15 +01:00
Evgeny Poberezkin 6729b1fb4f ios: update core library 2024-07-29 23:00:54 +01:00
Evgeny Poberezkin ac5351a752 core: 6.0.0.3 (simplexmq 6.0.0.5) 2024-07-29 22:14:23 +01:00
Evgeny Poberezkin 2ff4619ca4 ios: improve chat list layout (#4537) 2024-07-29 22:11:02 +01:00
spaced4ndy 493ad14b39 core: make user db actions high priority, faster chat start with async db operations (#4531)
* core: move db actions out of synchronous execution on chat start

* revert some

* multiplatform: load chat data before starting chat

* use priority database access

* simplexmq

* fix race in the tests

* check chat is running

* core: allow getting call invitations and notificationn token when chat is stopped

* ios: load chats and refresh call invitations before chat is started

* simplexmq

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-29 21:52:45 +01:00
Arturs Krumins 7f08f87ee4 ios: improve conversation scrolling (fixes hangs when messages are updated). (#4534)
* ios: fix hang while updating chat item state

* throttle item update

* fix

* remove buttons, switch back to Debug

* remove items getter/setter from ChatModel

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-29 21:17:14 +01:00
Evgeny Poberezkin ce1b66cef2 ios: optimize marking messages read (#4530)
* ios: optimize marking messages read

* remove view ifs
2024-07-29 09:49:43 +01:00
Evgeny Poberezkin 3e18fdc34d ios: link framework into share extension without embedding 2024-07-28 23:28:53 +01:00
Evgeny Poberezkin c09075d71e ios: update core library 2024-07-28 23:05:41 +01:00
Evgeny Poberezkin 41b4d7851a ios: improve chat list layout (#4528) 2024-07-28 21:53:21 +01:00
Evgeny Poberezkin 92bec5eabb core: 6.0.0.2 (simplexmq 6.0.0.4) 2024-07-28 18:11:38 +01:00
Stanislav Dmitrenko 24587ecc92 scripts (windows): add follow redirects to curl (#4526) 2024-07-28 17:55:58 +01:00
Evgeny Poberezkin 6865515f43 ios: share extension (#4466)
* ios: share extension (#4414)

* ios: add share extension target

* ios: Add UI

* ios: send file from share-sheet

* image utils

* ShareError

* error handling; ui-cleanup

* progress bar; completion for direct chat

* cleanup

* cleanup

* ios: unify filter and sort between forward and share sheets

* ios: match share sheet styling with the main app

* ios: fix text input stroke width

* ios: align compose views

* more of the same...

* ShareAPI

* remove combine

* minor

* Better error descriptions

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: enable file sending workers in share extension (#4474)

* ios: align compose background, row height and fallback images for share-sheet (#4467)

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: coordinate database access between share extension, the app and notifications extension (#4472)

* ios: database management proposal

* Add SEState

* Global event loop

* minor

* reset state

* use apiCreateItem for local chats

* simplify waiting for suspension

* loading bar

* Dismiss share sheet with error

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* send image message (#4481)

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: improve share extension completion handling (#4486)

* improve completion handling

* minor

* show only spinner for group send

* rework event loop, errorAlert

* group chat timeout loading bar

* state machine WIP

* event loop actor

* alert

* errors text

* default

* file error

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: add remaining share types; process attachment in background on launch (#4510)

* add remaining share types; process attachment in background on launch

* cleanup diff

* revert `makeVideoQualityLower`

* reduce diff

* reduce diff

* iOS15 support

* process events when sharing link and text

* cleanup

* remove video file on failure

* cleanup CompletionHandler

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: share extension - additional alerts and media previews (#4521)

* add remaining share types; process attachment in background on launch

* cleanup diff

* revert `makeVideoQualityLower`

* reduce diff

* reduce diff

* iOS15 support

* process events when sharing link and text

* cleanup

* remove video file on failure

* cleanup CompletionHandler

* media previews

* network timeout alert

* revert framework compiler optimisation flag

* suspend chat after sheet dismiss

* activate chat

* update

* fix search

* sendMessageColor, file preview, chat deselect, simplify error action

* cleanup

* interupt database closing when sheet is reopened quickly

* cleanup redundant alert check

* restore package

* refactor previews, remove link preview

* show link preview when becomes available

* comment

* dont fail on invalid image

* suspend

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>

* ios: descriptive database errors (#4527)

* ios: set share extension as inactive when suspending chat

---------

Co-authored-by: Arturs Krumins <auth@levitatingpineapple.com>
2024-07-28 17:54:58 +01:00
Evgeny Poberezkin 5ee6f40e75 cli: allow passing ChatOpts parameter (#4525) 2024-07-28 12:06:07 +01:00
Evgeny Poberezkin 637189cc2d core: multiline output (#4520) 2024-07-26 15:21:06 +01:00
Evgeny Poberezkin 13bfc9e92b core: update app settings for migration (#4518) 2024-07-26 10:30:49 +01:00
Stanislav Dmitrenko 032c5d3a5b android, desktop: blur for media (#4508)
* android, desktop: blur for media

* change

* new option and applied blur to other elements

* new line

* added to migration

* long click handling

* hover on desktop

* changes

* change

* showDownloadButton function

* file rename

* don't blur when menu is visible

* rename
2024-07-26 09:29:13 +01:00
Evgeny Poberezkin a53333be20 ios: increase wallpaper scale (#4517) 2024-07-26 09:11:42 +01:00
Stanislav Dmitrenko a966f6b19d ios: blur for media (#4512)
* ios: blur for media

* line

* one more place

* changes for video

* using notification center

* change

* unused code

* string

* simplify

* refactor ifs

* fix

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-26 08:38:22 +01:00
Arturs Krumins 70d577260b ios: fix crash when changing screen orientation with tiled wallpaper (#4511)
* ios: fix crash, when displaying wallpaper

* simpler

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-25 20:19:44 +01:00
Stanislav Dmitrenko ea12982788 ios: fix two-line preview on IOS 17+ (#4514) 2024-07-25 14:53:10 +01:00
Stanislav Dmitrenko 6fca6c22c5 ios: interactive media and link previews in the list of chats (#4487)
* ios: interactive media and link previews in the list of chats

* commented out voice preview

* voice message support and various fixes

* changes to video

* changes

* playing voice in chat list with scrolling

* revert

This reverts commit 60f57403d1.

* prevent feedback loop

* version of dependency

* voice

* fix param

* working voice

* reacting on messages and chat deletion

* fix two videos in a row

* video item layout

* fix

---------

Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-23 18:11:42 +01:00
Evgeny Poberezkin e343dd017b core: fix nix build for iOS build with tagged JSON (e.g., Flutter apps) (#4484) 2024-07-22 19:35:36 +01:00
spaced4ndy 70a94d772a multiplatform: optimize subscription indicator (#4503) 2024-07-22 20:40:22 +04:00
spaced4ndy 2689d1e27b ios: optimize subscription indicator (#4501) 2024-07-22 19:32:54 +04:00
spaced4ndy 4a9b54fbaf core: get subs total api (#4500) 2024-07-22 19:06:53 +04:00
Evgeny Poberezkin f10a0ce58e core: receive only one notification message on push notification (#4504)
* core: receive only one notification message on push notification

* ios: receive only one notification message on push

* update stats to include notification server stats

* Codable

* update simplexmq
2024-07-22 15:48:57 +01:00
spaced4ndy 6d488ba489 android: improve proxy errors (#4489) 2024-07-19 20:10:31 +04:00
spaced4ndy 95776e0951 ios: improve proxy errors (#4485) 2024-07-19 18:39:12 +04:00
spaced4ndy bf9cdf3053 android: allow sending messages immediately on joinConnection, acceptContact (#4483) 2024-07-19 12:50:27 +04:00
spaced4ndy fa73e63a79 ios: allow sending messages immediately on joinConnection, acceptContact (#4478) 2024-07-19 11:31:43 +04:00
spaced4ndy bfab76ed90 core: allow sending messages immediately on joinConnection, acceptContact (#4465) 2024-07-18 20:33:51 +04:00
Stanislav Dmitrenko 905295ee5f android, desktop: interactive media and link previews in the list of chats (#4460)
* android, desktop: chat item content preview in chat list

* better

* moved code

* layout

* multiplier for files

* small

* changes

* changes

* changes

* no padding

* changes

* color

* multiplier

* changes

* fix state inconsistency in gallery

* voice messages improvements

* showing draft

* re-layout preview

* rename and padding

* fix

* padding

* link icon

* without offset

* image

* hand on hover

* color

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-18 16:26:06 +01:00
Evgeny Poberezkin a1e707ac1b ui: translations (#4469)
* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1934 of 1934 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 99.6% (1928 of 1934 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% (1934 of 1934 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% (1681 of 1681 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 99.6% (1928 of 1934 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (1934 of 1934 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% (1934 of 1934 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% (1681 of 1681 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 2.8% (55 of 1934 strings)

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

* Translated using Weblate (German)

Currently translated at 97.7% (1908 of 1952 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1934 of 1934 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 99.6% (1928 of 1934 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% (1934 of 1934 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% (1681 of 1681 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 99.6% (1928 of 1934 strings)

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

* Translated using Weblate (Hungarian)

Currently translated at 100.0% (1934 of 1934 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% (1934 of 1934 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% (1681 of 1681 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 2.8% (55 of 1934 strings)

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

* Translated using Weblate (German)

Currently translated at 97.7% (1908 of 1952 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 98.7% (1927 of 1952 strings)

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

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1952 of 1952 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% (1952 of 1952 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1952 of 1952 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% (1681 of 1681 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% (1952 of 1952 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 92.8% (1813 of 1952 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 100.0% (1952 of 1952 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% (1952 of 1952 strings)

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

* process localizations

---------

Co-authored-by: 大王叫我来巡山 <hamburger2048@users.noreply.hosted.weblate.org>
Co-authored-by: summoner001 <summoner@vivaldi.net>
Co-authored-by: Random <random-r@users.noreply.hosted.weblate.org>
Co-authored-by: M1K4 <oomikaoo@gmail.com>
Co-authored-by: Ghost of Sparta <makesocialfoss32@keemail.me>
Co-authored-by: MM <mecymyse@gmail.com>
Co-authored-by: mlanp <github@lang.xyz>
Co-authored-by: Miyu Sakatsuki <miyu-sakatsuki@outlook.jp>
Co-authored-by: jonnysemon <jonnysemon@users.noreply.hosted.weblate.org>
2024-07-17 19:00:47 +01:00
Evgeny Poberezkin fea51b75e9 ios: fix start chat api 2024-07-17 14:20:58 +01:00
Evgeny Poberezkin ff8bbf11e7 core: allow start in extension without subscriptions but with enabled files (#4464)
* core: allow start in extension without subscriptions but with enabled files

* only start sending files

* update

* update

* update simplexmq
2024-07-17 14:14:19 +01:00
Evgeny Poberezkin 391e9d57f2 docs: update transparency 2024-07-16 21:31:15 +01:00
Evgeny Poberezkin dae9f8575d ui: change labels in server stats, move percentage toggle (#4468)
* ui: change labels in server stats, move percentage toggle

* localizations
2024-07-16 20:41:00 +01:00
spaced4ndy 065d9be614 multiplatform: better errors (#4463) 2024-07-16 17:19:58 +04:00
spaced4ndy 4251762553 ios: better errors (#4462) 2024-07-16 17:19:37 +04:00
319 changed files with 16443 additions and 3188 deletions
+85 -57
View File
@@ -43,6 +43,21 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) {
items.append(item)
}
class ItemsModel: ObservableObject {
static let shared = ItemsModel()
private let publisher = ObservableObjectPublisher()
private var bag = Set<AnyCancellable>()
var reversedChatItems: [ChatItem] = [] {
willSet { publisher.send() }
}
init() {
publisher
.throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true)
.sink { self.objectWillChange.send() }
.store(in: &bag)
}
}
final class ChatModel: ObservableObject {
@Published var onboardingStage: OnboardingStage?
@Published var setDeliveryReceipts = false
@@ -69,7 +84,6 @@ final class ChatModel: ObservableObject {
@Published var networkStatuses: Dictionary<String, NetworkStatus> = [:]
// current chat
@Published var chatId: String?
@Published var reversedChatItems: [ChatItem] = []
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@@ -117,6 +131,8 @@ final class ChatModel: ObservableObject {
static let shared = ChatModel()
let im = ItemsModel.shared
static var ok: Bool { ChatModel.shared.chatDbStatus == .ok }
let ntfEnableLocal = true
@@ -343,7 +359,7 @@ final class ChatModel: ObservableObject {
var res: Bool
if let chat = getChat(cInfo.id) {
if let pItem = chat.chatItems.last {
if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) {
chat.chatItems = [cItem]
}
} else {
@@ -354,6 +370,9 @@ final class ChatModel: ObservableObject {
addChat(Chat(chatInfo: cInfo, chatItems: [cItem]))
res = true
}
if cItem.isDeletedContent || cItem.meta.itemDeleted != nil {
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
// update current chat
return chatId == cInfo.id ? _upsertChatItem(cInfo, cItem) : res
}
@@ -370,7 +389,7 @@ final class ChatModel: ObservableObject {
if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus {
ci.meta.itemStatus = status
}
reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0)
}
return true
}
@@ -394,12 +413,12 @@ final class ChatModel: ObservableObject {
}
private func _updateChatItem(at i: Int, with cItem: ChatItem) {
reversedChatItems[i] = cItem
reversedChatItems[i].viewTimestamp = .now
im.reversedChatItems[i] = cItem
im.reversedChatItems[i].viewTimestamp = .now
}
func getChatItemIndex(_ cItem: ChatItem) -> Int? {
reversedChatItems.firstIndex(where: { $0.id == cItem.id })
im.reversedChatItems.firstIndex(where: { $0.id == cItem.id })
}
func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) {
@@ -416,23 +435,24 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id {
if let i = getChatItemIndex(cItem) {
_ = withAnimation {
self.reversedChatItems.remove(at: i)
im.reversedChatItems.remove(at: i)
}
}
}
VoiceItemState.stopVoiceInChatView(cInfo, cItem)
}
func nextChatItemData<T>(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? {
guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil }
if previous {
while i < reversedChatItems.count - 1 {
while i < im.reversedChatItems.count - 1 {
i += 1
if let res = map(reversedChatItems[i]) { return res }
if let res = map(im.reversedChatItems[i]) { return res }
}
} else {
while i > 0 {
i -= 1
if let res = map(reversedChatItems[i]) { return res }
if let res = map(im.reversedChatItems[i]) { return res }
}
}
return nil
@@ -463,7 +483,7 @@ final class ChatModel: ObservableObject {
func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem {
let cItem = ChatItem.liveDummy(chatInfo.chatType)
withAnimation {
reversedChatItems.insert(cItem, at: 0)
im.reversedChatItems.insert(cItem, at: 0)
}
return cItem
}
@@ -471,15 +491,15 @@ final class ChatModel: ObservableObject {
func removeLiveDummy(animated: Bool = true) {
if hasLiveDummy {
if animated {
withAnimation { _ = reversedChatItems.removeFirst() }
withAnimation { _ = im.reversedChatItems.removeFirst() }
} else {
_ = reversedChatItems.removeFirst()
_ = im.reversedChatItems.removeFirst()
}
}
}
private var hasLiveDummy: Bool {
reversedChatItems.first?.isLiveDummy == true
im.reversedChatItems.first?.isLiveDummy == true
}
func markChatItemsRead(_ cInfo: ChatInfo) {
@@ -496,7 +516,7 @@ final class ChatModel: ObservableObject {
private func markCurrentChatRead(fromIndex i: Int = 0) {
var j = i
while j < reversedChatItems.count {
while j < im.reversedChatItems.count {
markChatItemRead_(j)
j += 1
}
@@ -510,7 +530,7 @@ final class ChatModel: ObservableObject {
var unreadBelow = 0
var j = i - 1
while j >= 0 {
if case .rcvNew = self.reversedChatItems[j].meta.itemStatus {
if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus {
unreadBelow += 1
}
j -= 1
@@ -545,7 +565,7 @@ final class ChatModel: ObservableObject {
// clear current chat
if chatId == cInfo.id {
chatItemStatuses = [:]
reversedChatItems = []
im.reversedChatItems = []
}
}
@@ -553,32 +573,58 @@ final class ChatModel: ObservableObject {
if chatId == cInfo.id,
let itemIndex = getChatItemIndex(cItem),
let chatIndex = getChatIndex(cInfo.id),
reversedChatItems[itemIndex].isRcvNew {
im.reversedChatItems[itemIndex].isRcvNew {
await MainActor.run {
withTransaction(Transaction()) {
// update current chat
markChatItemRead_(itemIndex)
// update preview
decreaseUnreadCounter(chatIndex)
unreadCollector.decreaseUnreadCounter(chatIndex)
}
}
}
}
private let unreadCollector = UnreadCollector()
class UnreadCollector {
private let subject = PassthroughSubject<Int, Never>()
private var bag = Set<AnyCancellable>()
private var dictionary = Dictionary<Int, Int>()
init() {
subject
.debounce(for: 1, scheduler: DispatchQueue.main)
.sink { _ in
self.dictionary.forEach { key, value in
ChatModel.shared.decreaseUnreadCounter(key, by: value)
}
self.dictionary = Dictionary<Int, Int>()
}
.store(in: &bag)
}
// Only call from main thread
func decreaseUnreadCounter(_ chatIndex: Int) {
dictionary[chatIndex] = (dictionary[chatIndex] ?? 0) + 1
subject.send(chatIndex)
}
}
private func markChatItemRead_(_ i: Int) {
let meta = reversedChatItems[i].meta
let meta = im.reversedChatItems[i].meta
if case .rcvNew = meta.itemStatus {
reversedChatItems[i].meta.itemStatus = .rcvRead
reversedChatItems[i].viewTimestamp = .now
im.reversedChatItems[i].meta.itemStatus = .rcvRead
im.reversedChatItems[i].viewTimestamp = .now
if meta.itemLive != true, let ttl = meta.itemTimed?.ttl {
reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl)
}
}
}
func decreaseUnreadCounter(_ chatIndex: Int) {
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount - 1
decreaseUnreadCounter(user: currentUser!)
func decreaseUnreadCounter(_ chatIndex: Int, by count: Int = 1) {
chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount - count
decreaseUnreadCounter(user: currentUser!, by: count)
}
func increaseUnreadCounter(user: any UserLike) {
@@ -608,8 +654,8 @@ final class ChatModel: ObservableObject {
var ns: [String] = []
if let ciCategory = chatItem.mergeCategory,
var i = getChatItemIndex(chatItem) {
while i < reversedChatItems.count {
let ci = reversedChatItems[i]
while i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
if ci.mergeCategory != ciCategory { break }
if let m = ci.memberConnected {
ns.append(m.displayName)
@@ -624,7 +670,7 @@ final class ChatModel: ObservableObject {
// returns the index of the passed item and the next item (it has smaller index)
func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) {
if let i = getChatItemIndex(ci) {
(i, i > 0 ? reversedChatItems[i - 1] : nil)
(i, i > 0 ? im.reversedChatItems[i - 1] : nil)
} else {
(nil, nil)
}
@@ -634,10 +680,10 @@ final class ChatModel: ObservableObject {
// and the previous visible item with another merge category
func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) {
guard var i = ciIndex else { return (nil, nil) }
let fst = reversedChatItems.count - 1
let fst = im.reversedChatItems.count - 1
while i < fst {
i = i + 1
let ci = reversedChatItems[i]
let ci = im.reversedChatItems[i]
if ciCategory == nil || ciCategory != ci.mergeCategory {
return (i - 1, ci)
}
@@ -650,7 +696,7 @@ final class ChatModel: ObservableObject {
var prevMember: GroupMember? = nil
var memberIds: Set<Int64> = []
for i in range {
if case let .groupRcv(m) = reversedChatItems[i].chatDir {
if case let .groupRcv(m) = im.reversedChatItems[i].chatDir {
if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m }
memberIds.insert(m.groupMemberId)
}
@@ -725,9 +771,9 @@ final class ChatModel: ObservableObject {
var i = 0
var totalBelow = 0
var unreadBelow = 0
while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) {
while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) {
totalBelow += 1
if reversedChatItems[i].isRcvNew {
if im.reversedChatItems[i].isRcvNew {
unreadBelow += 1
}
i += 1
@@ -736,12 +782,12 @@ final class ChatModel: ObservableObject {
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
let maxIx = reversedChatItems.count - 1
let maxIx = im.reversedChatItems.count - 1
var i = 0
let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) }
let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) }
while i < maxIx && !inView(i) { i += 1 }
while i < maxIx && inView(i) { i += 1 }
return reversedChatItems[min(i - 1, maxIx)]
return im.reversedChatItems[min(i - 1, maxIx)]
}
func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) {
@@ -774,7 +820,7 @@ struct UnreadChatItemCounts: Equatable {
var unreadBelow: Int
}
final class Chat: ObservableObject, Identifiable {
final class Chat: ObservableObject, Identifiable, ChatLike {
@Published var chatInfo: ChatInfo
@Published var chatItems: [ChatItem]
@Published var chatStats: ChatStats
@@ -825,24 +871,6 @@ final class Chat: ObservableObject, Identifiable {
var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } }
func groupFeatureEnabled(_ feature: GroupFeature) -> Bool {
if case let .group(groupInfo) = self.chatInfo {
let p = groupInfo.fullGroupPreferences
return switch feature {
case .timedMessages: p.timedMessages.on
case .directMessages: p.directMessages.on(for: groupInfo.membership)
case .fullDelete: p.fullDelete.on
case .reactions: p.reactions.on
case .voice: p.voice.on(for: groupInfo.membership)
case .files: p.files.on(for: groupInfo.membership)
case .simplexLinks: p.simplexLinks.on(for: groupInfo.membership)
case .history: p.history.on
}
} else {
return true
}
}
public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
}
+69 -71
View File
@@ -217,7 +217,7 @@ func apiDeleteUser(_ userId: Int64, _ delSMPQueues: Bool, viewPwd: String?) asyn
}
func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
let r = chatSendCmdSync(.startChat(mainApp: true), ctrl)
let r = chatSendCmdSync(.startChat(mainApp: true, enableSndFiles: true), ctrl)
switch r {
case .chatStarted: return true
case .chatRunning: return false
@@ -225,6 +225,15 @@ func apiStartChat(ctrl: chat_ctrl? = nil) throws -> Bool {
}
}
func apiCheckChatRunning() throws -> Bool {
let r = chatSendCmdSync(.checkChatRunning)
switch r {
case .chatRunning: return true
case .chatStopped: return false
default: throw r
}
}
func apiStopChat() async throws {
let r = await chatSendCmd(.apiStopChat)
switch r {
@@ -325,11 +334,12 @@ func loadChat(chat: Chat, search: String = "") {
do {
let cInfo = chat.chatInfo
let m = ChatModel.shared
let im = ItemsModel.shared
m.chatItemStatuses = [:]
m.reversedChatItems = []
im.reversedChatItems = []
let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search)
m.updateChatInfo(chat.chatInfo)
m.reversedChatItems = chat.chatItems.reversed()
im.reversedChatItems = chat.chatItems.reversed()
} catch let error {
logger.error("loadChat error: \(responseError(error))")
}
@@ -397,7 +407,7 @@ private func sendMessageErrorAlert(_ r: ChatResponse) {
logger.error("send message error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error sending message",
message: "Error: \(String(describing: r))"
message: "Error: \(responseError(r))"
)
}
@@ -405,7 +415,7 @@ private func createChatItemErrorAlert(_ r: ChatResponse) {
logger.error("apiCreateChatItem error: \(String(describing: r))")
AlertManager.shared.showAlertMsg(
title: "Error creating message",
message: "Error: \(String(describing: r))"
message: "Error: \(responseError(r))"
)
}
@@ -421,15 +431,15 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
throw r
}
func apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemId: itemId, mode: mode), bgDelay: msgDelay)
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
throw r
}
func apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) async throws -> (ChatItem, ChatItem?) {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, groupMemberId: groupMemberId, itemId: itemId), bgDelay: msgDelay)
if case let .chatItemDeleted(_, deletedChatItem, toChatItem, _) = r { return (deletedChatItem.chatItem, toChatItem?.chatItem) }
func apiDeleteMemberChatItems(groupId: Int64, itemIds: [Int64]) async throws -> [ChatItemDeletion] {
let r = await chatSendCmd(.apiDeleteMemberChatItem(groupId: groupId, itemIds: itemIds), bgDelay: msgDelay)
if case let .chatItemsDeleted(_, items, _) = r { return items }
throw r
}
@@ -699,7 +709,7 @@ func apiConnect_(incognito: Bool, connReq: String) async -> ((ConnReqType, Pendi
message: "Please check that you used the correct link or ask your contact to send you another one."
)
return (nil, alert)
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))):
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))):
let alert = mkAlert(
title: "Connection error (AUTH)",
message: "Unless your contact deleted the connection or this link was already used, it might be a bug - please report it.\nTo connect, please ask your contact to create another connection link and check that you have a stable network connection."
@@ -732,7 +742,7 @@ private func connectionErrorAlert(_ r: ChatResponse) -> Alert {
} else {
return mkAlert(
title: "Connection error",
message: "Error: \(String(describing: r))"
message: "Error: \(responseError(r))"
)
}
}
@@ -898,7 +908,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
let am = AlertManager.shared
if case let .acceptingContactRequest(_, contact) = r { return contact }
if case .chatCmdError(_, .errorAgent(.SMP(.AUTH))) = r {
if case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))) = r {
am.showAlertMsg(
title: "Connection error (AUTH)",
message: "Sender may have deleted the connection request."
@@ -909,7 +919,7 @@ func apiAcceptContactRequest(incognito: Bool, contactReqId: Int64) async -> Cont
logger.error("apiAcceptContactRequest error: \(String(describing: r))")
am.showAlertMsg(
title: "Error accepting contact request",
message: "Error: \(String(describing: r))"
message: "Error: \(responseError(r))"
)
}
return nil
@@ -935,7 +945,7 @@ func uploadStandaloneFile(user: any UserLike, file: CryptoFile, ctrl: chat_ctrl?
return (fileTransferMeta, nil)
} else {
logger.error("uploadStandaloneFile error: \(String(describing: r))")
return (nil, String(describing: r))
return (nil, responseError(r))
}
}
@@ -945,7 +955,7 @@ func downloadStandaloneFile(user: any UserLike, url: String, file: CryptoFile, c
return (rcvFileTransfer, nil)
} else {
logger.error("downloadStandaloneFile error: \(String(describing: r))")
return (nil, String(describing: r))
return (nil, responseError(r))
}
}
@@ -1025,7 +1035,7 @@ func apiReceiveFile(fileId: Int64, userApprovedRelays: Bool, encrypted: Bool, in
if !auto {
am.showAlertMsg(
title: "Error receiving file",
message: "Error: \(String(describing: r))"
message: "Error: \(responseError(r))"
)
}
}
@@ -1091,47 +1101,6 @@ func deleteRemoteCtrl(_ rcId: Int64) async throws {
try await sendCommandOkResp(.deleteRemoteCtrl(remoteCtrlId: rcId))
}
struct ErrorAlert {
var title: LocalizedStringKey
var message: LocalizedStringKey
}
func getNetworkErrorAlert(_ r: ChatResponse) -> ErrorAlert? {
switch r {
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TIMEOUT))):
return ErrorAlert(title: "Connection timeout", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .NETWORK))):
return ErrorAlert(title: "Connection error", message: "Please check your network connection with \(serverHostname(addr)) and try again.")
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .HOST))):
return ErrorAlert(title: "Connection error", message: "Server address is incompatible with network settings: \(serverHostname(addr)).")
case let .chatCmdError(_, .errorAgent(.BROKER(addr, .TRANSPORT(.version)))):
return ErrorAlert(title: "Connection error", message: "Server version is incompatible with your app: \(serverHostname(addr)).")
case let .chatCmdError(_, .errorAgent(.SMP(.PROXY(proxyErr)))):
return proxyErrorAlert(proxyErr)
case let .chatCmdError(_, .errorAgent(.PROXY(_, _, .protocolError(.PROXY(proxyErr))))):
return proxyErrorAlert(proxyErr)
default:
return nil
}
}
private func proxyErrorAlert(_ proxyErr: ProxyError) -> ErrorAlert? {
switch proxyErr {
case .BROKER(brokerErr: .TIMEOUT):
return ErrorAlert(title: "Private routing error", message: "Please try later.")
case .BROKER(brokerErr: .NETWORK):
return ErrorAlert(title: "Private routing error", message: "Please try later.")
case .NO_SESSION:
return ErrorAlert(title: "Private routing error", message: "Please try later.")
case .BROKER(brokerErr: .HOST):
return ErrorAlert(title: "Private routing error", message: "Server address is incompatible with network settings.")
case .BROKER(brokerErr: .TRANSPORT(.version)):
return ErrorAlert(title: "Private routing error", message: "Server version is incompatible with network settings.")
default:
return nil
}
}
func networkErrorAlert(_ r: ChatResponse) -> Alert? {
if let alert = getNetworkErrorAlert(r) {
return mkAlert(title: alert.title, message: alert.message)
@@ -1143,7 +1112,10 @@ func networkErrorAlert(_ r: ChatResponse) -> Alert? {
func acceptContactRequest(incognito: Bool, contactRequest: UserContactRequest) async {
if let contact = await apiAcceptContactRequest(incognito: incognito, contactReqId: contactRequest.apiId) {
let chat = Chat(chatInfo: ChatInfo.direct(contact: contact), chatItems: [])
DispatchQueue.main.async { ChatModel.shared.replaceChat(contactRequest.id, chat) }
DispatchQueue.main.async {
ChatModel.shared.replaceChat(contactRequest.id, chat)
ChatModel.shared.setContactNetworkStatus(contact, .connected)
}
}
}
@@ -1280,7 +1252,7 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult {
let r = await chatSendCmd(.apiJoinGroup(groupId: groupId))
switch r {
case let .userAcceptedGroupSent(_, groupInfo, _): return .joined(groupInfo: groupInfo)
case .chatCmdError(_, .errorAgent(.SMP(.AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorAgent(.SMP(_, .AUTH))): return .invitationRemoved
case .chatCmdError(_, .errorStore(.groupNotFound)): return .groupNotFound
default: throw r
}
@@ -1386,6 +1358,14 @@ func apiGetVersion() throws -> CoreVersionInfo {
throw r
}
func getAgentSubsTotal() throws -> (SMPServerSubs, Bool) {
let userId = try currentUserId("getAgentSubsTotal")
let r = chatSendCmdSync(.getAgentSubsTotal(userId: userId), log: false)
if case let .agentSubsTotal(_, subsTotal, hasSession) = r { return (subsTotal, hasSession) }
logger.error("getAgentSubsTotal error: \(String(describing: r))")
throw r
}
func getAgentServersSummary() throws -> PresentedServersSummary {
let userId = try currentUserId("getAgentServersSummary")
let r = chatSendCmdSync(.getAgentServersSummary(userId: userId), log: false)
@@ -1469,15 +1449,16 @@ func startChat(refreshInvitations: Bool = true) throws {
logger.debug("startChat")
let m = ChatModel.shared
try setNetworkConfig(getNetCfg())
let justStarted = try apiStartChat()
let chatRunning = try apiCheckChatRunning()
m.users = try listUsers()
if justStarted {
if !chatRunning {
try getUserChatData()
NtfManager.shared.setNtfBadgeCount(m.totalUnreadCountForAllUsers())
if (refreshInvitations) {
try refreshCallInvitations()
}
(m.savedToken, m.tokenStatus, m.notificationMode, m.notificationServer) = apiGetNtfToken()
_ = try apiStartChat()
// deviceToken is set when AppDelegate.application(didRegisterForRemoteNotificationsWithDeviceToken:) is called,
// when it is called before startChat
if let token = m.deviceToken {
@@ -1649,6 +1630,19 @@ func processReceivedMsg(_ res: ChatResponse) async {
}
}
}
case let .contactSndReady(user, contact):
if active(user) && contact.directOrUsed {
await MainActor.run {
m.updateContact(contact)
if let conn = contact.activeConn {
m.dismissConnReqView(conn.id)
m.removeChat(conn.id)
}
}
}
await MainActor.run {
m.setContactNetworkStatus(contact, .connected)
}
case let .receivedContactRequest(user, contactRequest):
if active(user) {
let cInfo = ChatInfo.contactRequest(contactRequest: contactRequest)
@@ -1752,21 +1746,25 @@ func processReceivedMsg(_ res: ChatResponse) async {
m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)
}
}
case let .chatItemDeleted(user, deletedChatItem, toChatItem, _):
case let .chatItemsDeleted(user, items, _):
if !active(user) {
if toChatItem == nil && deletedChatItem.chatItem.isRcvNew && deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
for item in items {
if item.toChatItem == nil && item.deletedChatItem.chatItem.isRcvNew && item.deletedChatItem.chatInfo.ntfsEnabled {
await MainActor.run {
m.decreaseUnreadCounter(user: user)
}
}
}
return
}
await MainActor.run {
if let toChatItem = toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(deletedChatItem.chatInfo, deletedChatItem.chatItem)
for item in items {
if let toChatItem = item.toChatItem {
_ = m.upsertChatItem(toChatItem.chatInfo, toChatItem.chatItem)
} else {
m.removeChatItem(item.deletedChatItem.chatInfo, item.deletedChatItem.chatItem)
}
}
}
case let .receivedGroupInvitation(user, groupInfo, _, _):
+12
View File
@@ -36,6 +36,18 @@ private func _suspendChat(timeout: Int) {
}
}
let seSubscriber = seMessageSubscriber {
switch $0 {
case let .state(state):
switch state {
case .inactive:
if AppChatState.shared.value.inactive { activateChat() }
case .sendingMessage:
if AppChatState.shared.value.canSuspend { suspendChat() }
}
}
}
func suspendChat() {
suspendLockQueue.sync {
_suspendChat(timeout: appSuspendTimeout)
+14 -12
View File
@@ -112,7 +112,7 @@ struct ChatInfoView: View {
case abortSwitchAddressAlert
case syncConnectionForceAlert
case queueInfo(info: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -155,23 +155,25 @@ struct ChatInfoView: View {
}
Section {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
sendReceiptsOption()
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
Group {
if let code = connectionCode { verifyCodeButton(code) }
contactPreferencesButton()
sendReceiptsOption()
if let connStats = connectionStats,
connStats.ratchetSyncAllowed {
synchronizeConnectionButton()
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
NavigationLink {
ChatWallpaperEditorSheet(chat: chat)
} label: {
Label("Chat theme", systemImage: "photo")
}
// } else if developerTools {
// synchronizeConnectionButtonForce()
// }
}
.disabled(!contact.ready || !contact.active)
if let conn = contact.activeConn {
Section {
@@ -271,7 +273,7 @@ struct ChatInfoView: View {
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
if contact.sndReady && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
@@ -9,6 +9,7 @@ import SwiftUI
class AnimatedImageView: UIView {
var image: UIImage? = nil
var imageView: UIImageView? = nil
var cMode: UIView.ContentMode = .scaleAspectFit
override init(frame: CGRect) {
super.init(frame: frame)
@@ -18,11 +19,12 @@ class AnimatedImageView: UIView {
fatalError("Not implemented")
}
convenience init(image: UIImage) {
convenience init(image: UIImage, contentMode: UIView.ContentMode) {
self.init()
self.image = image
self.cMode = contentMode
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
imageView!.contentMode = contentMode
self.addSubview(imageView!)
}
@@ -35,7 +37,7 @@ class AnimatedImageView: UIView {
if let subview = self.subviews.first as? UIImageView {
if image.imageData != subview.gifImage?.imageData {
imageView = UIImageView(gifImage: image)
imageView!.contentMode = .scaleAspectFit
imageView!.contentMode = contentMode
self.addSubview(imageView!)
subview.removeFromSuperview()
}
@@ -47,13 +49,15 @@ class AnimatedImageView: UIView {
struct SwiftyGif: UIViewRepresentable {
private let image: UIImage
private let contentMode: UIView.ContentMode
init(image: UIImage) {
init(image: UIImage, contentMode: UIView.ContentMode = .scaleAspectFit) {
self.image = image
self.contentMode = contentMode
}
func makeUIView(context: Context) -> AnimatedImageView {
AnimatedImageView(image: image)
AnimatedImageView(image: image, contentMode: contentMode)
}
func updateUIView(_ imageView: AnimatedImageView, context: Context) {
@@ -11,6 +11,7 @@ import SimpleXChat
struct CIChatFeatureView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var im = ItemsModel.shared
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@@ -53,8 +54,8 @@ struct CIChatFeatureView: View {
var fs: [FeatureInfo] = []
var icons: Set<String> = []
if var i = m.getChatItemIndex(chatItem) {
while i < m.reversedChatItems.count,
let f = featureInfo(m.reversedChatItems[i]) {
while i < im.reversedChatItems.count,
let f = featureInfo(im.reversedChatItems[i]) {
if !icons.contains(f.icon) {
fs.insert(f, at: 0)
icons.insert(f.icon)
@@ -14,39 +14,45 @@ struct CIFileView: View {
@EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
var smallViewSize: CGFloat?
var body: some View {
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
if smallViewSize != nil {
fileIndicator()
.onTapGesture(perform: fileAction)
} else {
let metaReserve = edited
? " "
: " "
Button(action: fileAction) {
HStack(alignment: .bottom, spacing: 6) {
fileIndicator()
.padding(.top, 5)
.padding(.bottom, 3)
if let file = file {
let prettyFileSize = ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .binary)
VStack(alignment: .leading, spacing: 2) {
Text(file.fileName)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.onBackground)
Text(prettyFileSize + metaReserve)
.font(.caption)
.lineLimit(1)
.multilineTextAlignment(.leading)
.foregroundColor(theme.colors.secondary)
}
} else {
Text(metaReserve)
}
} else {
Text(metaReserve)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
}
.padding(.top, 4)
.padding(.bottom, 6)
.padding(.leading, 10)
.padding(.trailing, 12)
.disabled(!itemInteractive)
}
.disabled(!itemInteractive)
}
private var itemInteractive: Bool {
@@ -195,21 +201,22 @@ struct CIFileView: View {
}
private func fileIcon(_ icon: String, color: Color = Color(uiColor: .tertiaryLabel), innerIcon: String? = nil, innerIconSize: CGFloat? = nil) -> some View {
ZStack(alignment: .center) {
let size = smallViewSize ?? 30
return ZStack(alignment: .center) {
Image(systemName: icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.frame(width: size, height: size)
.foregroundColor(color)
if let innerIcon = innerIcon,
let innerIconSize = innerIconSize {
let innerIconSize = innerIconSize, (smallViewSize == nil || file?.showStatusIconInSmallView == true) {
Image(systemName: innerIcon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxHeight: 16)
.frame(width: innerIconSize, height: innerIconSize)
.foregroundColor(.white)
.padding(.top, 12)
.padding(.top, size / 2.5)
}
}
}
@@ -15,22 +15,33 @@ struct CIImageView: View {
var preview: UIImage?
let maxWidth: CGFloat
var imgWidth: CGFloat?
@State private var showFullScreenImage = false
var smallView: Bool = false
@Binding var showFullScreenImage: Bool
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
let file = chatItem.file
VStack(alignment: .center, spacing: 6) {
if let uiImage = getLoadedImage(file) {
imageView(uiImage)
Group { if smallView { smallViewImageView(uiImage) } else { imageView(uiImage) } }
.fullScreenCover(isPresented: $showFullScreenImage) {
FullScreenMediaView(chatItem: chatItem, image: uiImage, showView: $showFullScreenImage)
}
.if(!smallView) { view in
view.modifier(PrivacyBlur(blurred: $blurred))
}
.onTapGesture { showFullScreenImage = true }
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenImage = false
}
} else if let preview {
imageView(preview)
Group {
if smallView {
smallViewImageView(preview)
} else {
imageView(preview).modifier(PrivacyBlur(blurred: $blurred))
}
}
.onTapGesture {
if let file = file {
switch file.fileStatus {
@@ -83,6 +94,9 @@ struct CIImageView: View {
}
}
}
.onDisappear {
showFullScreenImage = false
}
}
private func imageView(_ img: UIImage) -> some View {
@@ -98,7 +112,26 @@ struct CIImageView: View {
.frame(width: w, height: w * img.size.height / img.size.width)
.scaledToFit()
}
loadingIndicator()
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
loadingIndicator()
}
}
}
private func smallViewImageView(_ img: UIImage) -> some View {
ZStack(alignment: .topTrailing) {
if img.imageData == nil {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
} else {
SwiftyGif(image: img, contentMode: .scaleAspectFill)
.frame(width: maxWidth, height: maxWidth)
}
if chatItem.file?.showStatusIconInSmallView == true {
loadingIndicator()
}
}
}
@@ -145,4 +178,12 @@ struct CIImageView: View {
.tint(.white)
.padding(8)
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
default: false
}
}
}
@@ -12,6 +12,7 @@ import SimpleXChat
struct CILinkView: View {
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
var body: some View {
VStack(alignment: .center, spacing: 6) {
@@ -19,6 +20,7 @@ struct CILinkView: View {
Image(uiImage: uiImage)
.resizable()
.scaledToFit()
.modifier(PrivacyBlur(blurred: $blurred))
}
VStack(alignment: .leading, spacing: 6) {
Text(linkPreview.title)
@@ -27,7 +27,7 @@ struct CIRcvDecryptionError: View {
case syncNotSupportedContactAlert
case syncNotSupportedMemberAlert
case decryptionErrorAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -62,7 +62,7 @@ struct CIRcvDecryptionError: View {
case .syncNotSupportedContactAlert: return Alert(title: Text("Fix not supported by contact"), message: message())
case .syncNotSupportedMemberAlert: return Alert(title: Text("Fix not supported by group member"), message: message())
case .decryptionErrorAlert: return Alert(title: Text("Decryption error"), message: message())
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
}
@@ -20,45 +20,44 @@ struct CIVideoView: View {
@State private var videoPlaying: Bool = false
private let maxWidth: CGFloat
private var videoWidth: CGFloat?
private let smallView: Bool
@State private var player: AVPlayer?
@State private var fullPlayer: AVPlayer?
@State private var url: URL?
@State private var urlDecrypted: URL?
@State private var decryptionInProgress: Bool = false
@State private var showFullScreenPlayer = false
@Binding private var showFullScreenPlayer: Bool
@State private var timeObserver: Any? = nil
@State private var fullScreenTimeObserver: Any? = nil
@State private var publisher: AnyCancellable? = nil
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?) {
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
self.chatItem = chatItem
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
self.videoWidth = videoWidth
if let url = getLoadedVideo(chatItem.file) {
let decrypted = chatItem.file?.fileSource?.cryptoArgs == nil ? url : chatItem.file?.fileSource?.decryptedGet()
self._urlDecrypted = State(initialValue: decrypted)
if let decrypted = decrypted {
self._player = State(initialValue: VideoPlayerView.getOrCreatePlayer(decrypted, false))
self._fullPlayer = State(initialValue: AVPlayer(url: decrypted))
}
self._url = State(initialValue: url)
}
self.smallView = smallView
self._showFullScreenPlayer = showFullscreenPlayer
}
var body: some View {
let file = chatItem.file
ZStack {
ZStack(alignment: smallView ? .topLeading : .center) {
ZStack(alignment: .topLeading) {
if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
if let file = file, let preview = preview, let decrypted = urlDecrypted, smallView {
smallVideoView(decrypted, file, preview)
} else if let file = file, let preview = preview, let player = player, let decrypted = urlDecrypted {
videoView(player, decrypted, file, preview, duration)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil, smallView {
smallVideoViewEncrypted(file, defaultPreview)
} else if let file = file, let defaultPreview = preview, file.loaded && urlDecrypted == nil {
videoViewEncrypted(file, defaultPreview, duration)
} else if let preview {
imageView(preview)
.onTapGesture {
if let file = file {
} else if let preview, let file {
Group { if smallView { smallViewImageView(preview, file) } else { imageView(preview) } }
.onTapGesture {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
@@ -82,21 +81,64 @@ struct CIVideoView: View {
default: ()
}
}
}
}
durationProgress()
if !smallView {
durationProgress()
}
}
if let file = file, showDownloadButton(file.fileStatus) {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
if !blurred, let file, showDownloadButton(file.fileStatus) {
if !smallView {
Button {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
} label: {
playPauseIcon("play.fill")
}
} else if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
.onTapGesture {
receiveFileIfValidSize(file: file, receiveFile: receiveFile)
}
}
}
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onAppear {
setupPlayer(chatItem.file)
}
.onChange(of: chatItem.file) { file in
// ChatItem can be changed in small view on chat list screen
setupPlayer(file)
}
.onDisappear {
showFullScreenPlayer = false
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus) -> Bool {
private func setupPlayer(_ file: CIFile?) {
let newUrl = getLoadedVideo(file)
if newUrl == url {
return
}
url = nil
urlDecrypted = nil
player = nil
fullPlayer = nil
if let newUrl {
let decrypted = file?.fileSource?.cryptoArgs == nil ? newUrl : file?.fileSource?.decryptedGet()
urlDecrypted = decrypted
if let decrypted = decrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
fullPlayer = AVPlayer(url: decrypted)
}
url = newUrl
}
}
private func showDownloadButton(_ fileStatus: CIFileStatus?) -> Bool {
switch fileStatus {
case .rcvInvitation: true
case .rcvAborted: true
@@ -109,11 +151,6 @@ struct CIVideoView: View {
ZStack(alignment: .center) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
imageView(defaultPreview)
.fullScreenCover(isPresented: $showFullScreenPlayer) {
if let decrypted = urlDecrypted {
fullScreenPlayer(decrypted)
}
}
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
@@ -122,20 +159,22 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !decryptionInProgress {
Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
if !blurred {
if !decryptionInProgress {
Button {
decrypt(file: file) {
if urlDecrypted != nil {
videoPlaying = true
player?.play()
}
}
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
}
} label: {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
.disabled(!canBePlayed)
} else {
videoDecryptionProgress()
}
}
}
@@ -154,9 +193,7 @@ struct CIVideoView: View {
videoPlaying = false
}
}
.fullScreenCover(isPresented: $showFullScreenPlayer) {
fullScreenPlayer(url)
}
.modifier(PrivacyBlur(enabled: !videoPlaying, blurred: $blurred))
.onTapGesture {
switch player.timeControlStatus {
case .playing:
@@ -172,7 +209,7 @@ struct CIVideoView: View {
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !videoPlaying {
if !videoPlaying && !blurred {
Button {
m.stopPreviousRecPlay = url
player.play()
@@ -194,14 +231,53 @@ struct CIVideoView: View {
}
}
private func smallVideoViewEncrypted(_ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
let canBePlayed = !chatItem.chatDir.sent || file.fileStatus == CIFileStatus.sndComplete || (file.fileStatus == .sndStored && file.fileProtocol == .local)
smallViewImageView(preview, file)
.onTapGesture {
decrypt(file: file) {
showFullScreenPlayer = urlDecrypted != nil
}
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if file.showStatusIconInSmallView {
// Show nothing
} else if !decryptionInProgress {
playPauseIcon(canBePlayed ? "play.fill" : "play.slash")
} else {
videoDecryptionProgress()
}
}
}
private func smallVideoView(_ url: URL, _ file: CIFile, _ preview: UIImage) -> some View {
return ZStack(alignment: .topLeading) {
smallViewImageView(preview, file)
.onTapGesture {
showFullScreenPlayer = true
}
.onChange(of: m.activeCallViewIsCollapsed) { _ in
showFullScreenPlayer = false
}
if !file.showStatusIconInSmallView {
playPauseIcon("play.fill")
}
}
}
private func playPauseIcon(_ image: String, _ color: Color = .white) -> some View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 12, height: 12)
.frame(width: smallView ? 12 * sizeMultiplier * 1.6 : 12, height: smallView ? 12 * sizeMultiplier * 1.6 : 12)
.foregroundColor(color)
.padding(.leading, 4)
.frame(width: 40, height: 40)
.padding(.leading, smallView ? 0 : 4)
.frame(width: 40 * sizeMultiplier, height: 40 * sizeMultiplier)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -209,9 +285,9 @@ struct CIVideoView: View {
private func videoDecryptionProgress(_ color: Color = .white) -> some View {
ProgressView()
.progressViewStyle(.circular)
.frame(width: 12, height: 12)
.frame(width: smallView ? 12 * sizeMultiplier : 12, height: smallView ? 12 * sizeMultiplier : 12)
.tint(color)
.frame(width: 40, height: 40)
.frame(width: smallView ? 40 * sizeMultiplier * 0.9 : 40, height: smallView ? 40 * sizeMultiplier * 0.9 : 40)
.background(Color.black.opacity(0.35))
.clipShape(Circle())
}
@@ -247,7 +323,23 @@ struct CIVideoView: View {
.resizable()
.scaledToFit()
.frame(width: w)
fileStatusIcon()
.modifier(PrivacyBlur(blurred: $blurred))
if !blurred || !showDownloadButton(chatItem.file?.fileStatus) {
fileStatusIcon()
}
}
}
private func smallViewImageView(_ img: UIImage, _ file: CIFile) -> some View {
ZStack(alignment: .center) {
Image(uiImage: img)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: maxWidth, height: maxWidth)
if file.showStatusIconInSmallView {
fileStatusIcon()
.allowsHitTesting(false)
}
}
}
@@ -322,7 +414,7 @@ struct CIVideoView: View {
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.foregroundColor(.white)
.padding(padding)
.padding(smallView ? 0 : padding)
}
private func progressView() -> some View {
@@ -330,7 +422,7 @@ struct CIVideoView: View {
.progressViewStyle(.circular)
.frame(width: 16, height: 16)
.tint(.white)
.padding(11)
.padding(smallView ? 0 : 11)
}
private func progressCircle(_ progress: Int64, _ total: Int64) -> some View {
@@ -342,7 +434,7 @@ struct CIVideoView: View {
)
.rotationEffect(.degrees(-90))
.frame(width: 16, height: 16)
.padding([.trailing, .top], 11)
.padding([.trailing, .top], smallView ? 0 : 11)
}
// TODO encrypt: where file size is checked?
@@ -382,7 +474,8 @@ struct CIVideoView: View {
)
.onAppear {
DispatchQueue.main.asyncAfter(deadline: .now()) {
m.stopPreviousRecPlay = url
// Prevent feedback loop - setting `ChatModel`s property causes `onAppear` to be called on iOS17+
if m.stopPreviousRecPlay != url { m.stopPreviousRecPlay = url }
if let player = fullPlayer {
player.play()
var played = false
@@ -419,10 +512,12 @@ struct CIVideoView: View {
urlDecrypted = await file.fileSource?.decryptedGetOrCreate(&ChatModel.shared.filesToDelete)
await MainActor.run {
if let decrypted = urlDecrypted {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
if !smallView {
player = VideoPlayerView.getOrCreatePlayer(decrypted, false)
}
fullPlayer = AVPlayer(url: decrypted)
}
decryptionInProgress = true
decryptionInProgress = false
completed?()
}
}
@@ -15,15 +15,26 @@ struct CIVoiceView: View {
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool
var smallViewSize: CGFloat?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
Group {
if chatItem.chatDir.sent {
if smallViewSize != nil {
HStack(spacing: 10) {
player()
playerTime()
.allowsHitTesting(false)
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
playbackSlider()
}
}
} else if chatItem.chatDir.sent {
VStack (alignment: .trailing, spacing: 6) {
HStack {
if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu {
@@ -54,7 +65,13 @@ struct CIVoiceView: View {
}
private func player() -> some View {
VoiceMessagePlayer(
let sizeMultiplier: CGFloat = if let sz = smallViewSize {
voiceMessageSizeBasedOnSquareSize(sz) / 56
} else {
1
}
return VoiceMessagePlayer(
chat: chat,
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -63,7 +80,8 @@ struct CIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu
allowMenu: $allowMenu,
sizeMultiplier: sizeMultiplier
)
}
@@ -119,6 +137,7 @@ struct VoiceMessagePlayerTime: View {
}
struct VoiceMessagePlayer: View {
@ObservedObject var chat: Chat
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
var chatItem: ChatItem
@@ -130,7 +149,9 @@ struct VoiceMessagePlayer: View {
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@Binding var allowMenu: Bool
var sizeMultiplier: CGFloat
var body: some View {
ZStack {
@@ -190,49 +211,113 @@ struct VoiceMessagePlayer: View {
}
}
.onAppear {
if audioPlayer == nil {
let small = sizeMultiplier != 1
audioPlayer = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.audioPlayer : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.audioPlayer
playbackState = (small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackState : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackState) ?? .noPlayback
playbackTime = small ? VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)]?.playbackTime : VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)]?.playbackTime
}
seek = { to in audioPlayer?.seek(to) }
audioPlayer?.onTimer = { playbackTime = $0 }
let audioPath: URL? = if let recordingSource = getLoadedFileSource(recordingFile) {
getAppFilePath(recordingSource.filePath)
} else {
nil
}
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
audioPlayer?.onTimer = {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if (audioPath != nil && chatModel.stopPreviousRecPlay != audioPath) || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
}
audioPlayer?.onFinishPlayback = {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
// One voice message was paused, then scrolled far from it, started to play another one, drop to stopped state
if let audioPath, chatModel.stopPreviousRecPlay != audioPath {
stopPlayback()
}
}
.onChange(of: chatModel.stopPreviousRecPlay) { it in
if let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
stopPlayback()
}
}
.onChange(of: playbackState) { state in
allowMenu = state == .paused || state == .noPlayback
// Notify activeContentPreview in ChatPreviewView that playback is finished
if state == .noPlayback, let recordingFileName = getLoadedFileSource(recordingFile)?.filePath,
chatModel.stopPreviousRecPlay == getAppFilePath(recordingFileName) {
chatModel.stopPreviousRecPlay = nil
}
}
.onChange(of: chatModel.chatId) { _ in
stopPlayback()
}
.onDisappear {
if sizeMultiplier == 1 && chatModel.chatId == nil {
stopPlayback()
}
}
}
@ViewBuilder private func playbackButton() -> some View {
switch playbackState {
case .noPlayback:
Button {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
} label: {
if sizeMultiplier != 1 {
switch playbackState {
case .noPlayback:
playPauseIcon("play.fill", theme.colors.primary)
}
case .playing:
Button {
audioPlayer?.pause()
playbackState = .paused
} label: {
.onTapGesture {
if let recordingSource = getLoadedFileSource(recordingFile) {
startPlayback(recordingSource)
}
}
case .playing:
playPauseIcon("pause.fill", theme.colors.primary)
}
case .paused:
Button {
audioPlayer?.play()
playbackState = .playing
} label: {
.onTapGesture {
audioPlayer?.pause()
playbackState = .paused
notifyStateChange()
}
case .paused:
playPauseIcon("play.fill", theme.colors.primary)
.onTapGesture {
audioPlayer?.play()
playbackState = .playing
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)
}
}
}
}
@@ -242,28 +327,49 @@ struct VoiceMessagePlayer: View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.frame(width: 20 * sizeMultiplier, height: 20 * sizeMultiplier)
.foregroundColor(color)
.padding(.leading, image == "play.fill" ? 4 : 0)
.frame(width: 56, height: 56)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
if recordingTime > 0 {
ProgressCircle(length: recordingTime, progress: $playbackTime)
.frame(width: 53, height: 53) // this + ProgressCircle lineWidth = background circle diameter
.frame(width: 53 * sizeMultiplier, height: 53 * sizeMultiplier) // this + ProgressCircle lineWidth = background circle diameter
}
}
}
private func downloadButton(_ recordingFile: CIFile, _ icon: String) -> some View {
Button {
Task {
if let user = chatModel.currentUser {
await receiveFile(user: user, fileId: recordingFile.fileId)
Group {
if sizeMultiplier != 1 {
playPauseIcon(icon, theme.colors.primary)
.onTapGesture {
Task {
if let user = chatModel.currentUser {
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)
}
}
} label: {
playPauseIcon(icon, theme.colors.primary)
}
}
func notifyStateChange() {
if sizeMultiplier != 1 {
VoiceItemState.smallView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
} else {
VoiceItemState.chatView[VoiceItemState.id(chat, chatItem)] = VoiceItemState(audioPlayer: audioPlayer, playbackState: playbackState, playbackTime: playbackTime)
}
}
@@ -288,33 +394,96 @@ struct VoiceMessagePlayer: View {
Image(systemName: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: size, height: size)
.frame(width: size * sizeMultiplier, height: size * sizeMultiplier)
.foregroundColor(Color(uiColor: .tertiaryLabel))
.frame(width: 56, height: 56)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func loadingIcon() -> some View {
ProgressView()
.frame(width: 30, height: 30)
.frame(width: 56, height: 56)
.frame(width: 30 * sizeMultiplier, height: 30 * sizeMultiplier)
.frame(width: 56 * sizeMultiplier, height: 56 * sizeMultiplier)
.background(showBackground ? chatItemFrameColor(chatItem, theme) : .clear)
.clipShape(Circle())
}
private func startPlayback(_ recordingSource: CryptoFile) {
chatModel.stopPreviousRecPlay = getAppFilePath(recordingSource.filePath)
let audioPath = getAppFilePath(recordingSource.filePath)
let chatId = chatModel.chatId
let userId = chatModel.currentUser?.userId
chatModel.stopPreviousRecPlay = audioPath
audioPlayer = AudioPlayer(
onTimer: { playbackTime = $0 },
onTimer: {
playbackTime = $0
notifyStateChange()
// Manual check here is needed because when this view is not visible, SwiftUI don't react on stopPreviousRecPlay, chatId and current user changes and audio keeps playing when it should stop
if chatModel.stopPreviousRecPlay != audioPath || chatModel.chatId != chatId || chatModel.currentUser?.userId != userId {
stopPlayback()
}
},
onFinishPlayback: {
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
)
audioPlayer?.start(fileSource: recordingSource, at: playbackTime)
playbackState = .playing
notifyStateChange()
}
private func stopPlayback() {
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
notifyStateChange()
}
}
func voiceMessageSizeBasedOnSquareSize(_ squareSize: CGFloat) -> CGFloat {
let squareToCircleRatio = 0.935
return squareSize + squareSize * (1 - squareToCircleRatio)
}
class VoiceItemState {
var audioPlayer: AudioPlayer?
var playbackState: VoiceMessagePlaybackState
var playbackTime: TimeInterval?
init(audioPlayer: AudioPlayer? = nil, playbackState: VoiceMessagePlaybackState, playbackTime: TimeInterval? = nil) {
self.audioPlayer = audioPlayer
self.playbackState = playbackState
self.playbackTime = playbackTime
}
static func id(_ chat: Chat, _ chatItem: ChatItem) -> String {
"\(chat.id) \(chatItem.id)"
}
static func id(_ chatInfo: ChatInfo, _ chatItem: ChatItem) -> String {
"\(chatInfo.id) \(chatItem.id)"
}
static func stopVoiceInSmallView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = smallView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static func stopVoiceInChatView(_ chatInfo: ChatInfo, _ chatItem: ChatItem) {
let id = id(chatInfo, chatItem)
if let item = chatView[id] {
item.audioPlayer?.stop()
ChatModel.shared.stopPreviousRecPlay = nil
}
}
static var smallView: [String: VoiceItemState] = [:]
static var chatView: [String: VoiceItemState] = [:]
}
struct CIVoiceView_Previews: PreviewProvider {
@@ -339,15 +508,12 @@ struct CIVoiceView_Previews: PreviewProvider {
chatItem: ChatItem.getVoiceMsgContentSample(),
recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete),
duration: 30,
audioPlayer: .constant(nil),
playbackState: .constant(.playing),
playbackTime: .constant(TimeInterval(20)),
allowMenu: Binding.constant(true)
)
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil))
ChatItemView(chat: Chat.sampleData, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true))
ChatItemView(chat: Chat.sampleData, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true))
}
.previewLayout(.fixed(width: 360, height: 360))
}
@@ -13,21 +13,23 @@ import SimpleXChat
struct FramedCIVoiceView: View {
@EnvironmentObject var theme: AppTheme
@ObservedObject var chat: Chat
var chatItem: ChatItem
let recordingFile: CIFile?
let duration: Int
@State var audioPlayer: AudioPlayer? = nil
@State var playbackState: VoiceMessagePlaybackState = .noPlayback
@State var playbackTime: TimeInterval? = nil
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
@State private var seek: (TimeInterval) -> Void = { _ in }
var body: some View {
HStack {
VoiceMessagePlayer(
chat: chat,
chatItem: chatItem,
recordingFile: recordingFile,
recordingTime: TimeInterval(duration),
@@ -36,7 +38,8 @@ struct FramedCIVoiceView: View {
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime,
allowMenu: $allowMenu
allowMenu: $allowMenu,
sizeMultiplier: 1
)
VoiceMessagePlayerTime(
recordingTime: TimeInterval(duration),
File diff suppressed because one or more lines are too long
@@ -35,8 +35,8 @@ struct MarkedDeletedItemView: View {
var blockedByAdmin = 0
var deleted = 0
var moderatedBy: Set<String> = []
while i < m.reversedChatItems.count,
let ci = .some(m.reversedChatItems[i]),
while i < ItemsModel.shared.reversedChatItems.count,
let ci = .some(ItemsModel.shared.reversedChatItems[i]),
ci.mergeCategory == ciCategory,
let itemDeleted = ci.meta.itemDeleted {
switch itemDeleted {
@@ -21,8 +21,7 @@ struct ChatItemForwardingView: View {
@State private var searchText: String = ""
@FocusState private var searchFocused
@State private var alert: SomeAlert?
@State private var hasSimplexLink_: Bool?
private let chatsToForwardTo = filterChatsToForwardTo()
private let chatsToForwardTo = filterChatsToForwardTo(chats: ChatModel.shared.chats)
var body: some View {
NavigationView {
@@ -67,50 +66,6 @@ struct ChatItemForwardingView: View {
}
}
private func foundChat(_ chat: Chat, _ searchStr: String) -> Bool {
let cInfo = chat.chatInfo
return switch cInfo {
case let .direct(contact):
viewNameContains(cInfo, searchStr) ||
contact.profile.displayName.localizedLowercase.contains(searchStr) ||
contact.fullName.localizedLowercase.contains(searchStr)
default:
viewNameContains(cInfo, searchStr)
}
func viewNameContains(_ cInfo: ChatInfo, _ s: String) -> Bool {
cInfo.chatViewName.localizedLowercase.contains(s)
}
}
private func prohibitedByPref(_ chat: Chat) -> Bool {
// preference checks should match checks in compose view
let simplexLinkProhibited = hasSimplexLink && !chat.groupFeatureEnabled(.simplexLinks)
let fileProhibited = (ci.content.msgContent?.isMediaOrFileAttachment ?? false) && !chat.groupFeatureEnabled(.files)
let voiceProhibited = (ci.content.msgContent?.isVoice ?? false) && !chat.chatInfo.featureEnabled(.voice)
return switch chat.chatInfo {
case .direct: voiceProhibited
case .group: simplexLinkProhibited || fileProhibited || voiceProhibited
case .local: false
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
private var hasSimplexLink: Bool {
if let hasSimplexLink_ { return hasSimplexLink_ }
let r =
if let mcText = ci.content.msgContent?.text,
let parsedMsg = parseSimpleXMarkdown(mcText) {
parsedMsgHasSimplexLink(parsedMsg)
} else {
false
}
hasSimplexLink_ = r
return r
}
private func emptyList() -> some View {
Text("No filtered chats")
.foregroundColor(theme.colors.secondary)
@@ -118,7 +73,11 @@ struct ChatItemForwardingView: View {
}
@ViewBuilder private func forwardListChatView(_ chat: Chat) -> some View {
let prohibited = prohibitedByPref(chat)
let prohibited = chat.prohibitedByPref(
hasSimplexLink: hasSimplexLink(ci.content.msgContent?.text),
isMediaOrFileAttachment: ci.content.msgContent?.isMediaOrFileAttachment ?? false,
isVoice: ci.content.msgContent?.isVoice ?? false
)
Button {
if prohibited {
alert = SomeAlert(
@@ -162,27 +121,6 @@ struct ChatItemForwardingView: View {
}
}
private func filterChatsToForwardTo() -> [Chat] {
var filteredChats = ChatModel.shared.chats.filter { c in
c.chatInfo.chatType != .local && canForwardToChat(c)
}
if let privateNotes = ChatModel.shared.chats.first(where: { $0.chatInfo.chatType == .local }) {
filteredChats.insert(privateNotes, at: 0)
}
return filteredChats
}
private func canForwardToChat(_ chat: Chat) -> Bool {
switch chat.chatInfo {
case let .direct(contact): contact.sendMsgEnabled && !contact.nextSendGrpInv
case let .group(groupInfo): groupInfo.sendMsgEnabled
case let .local(noteFolder): noteFolder.sendMsgEnabled
case .contactRequest: false
case .contactConnection: false
case .invalidJSON: false
}
}
#Preview {
ChatItemForwardingView(
ci: ChatItem.getSample(1, .directSnd, .now, "hello"),
@@ -190,3 +128,4 @@ private func canForwardToChat(_ chat: Chat) -> Bool {
composeState: Binding.constant(ComposeState(message: "hello"))
).environmentObject(CurrentColors.toAppTheme())
}
+4 -15
View File
@@ -16,28 +16,20 @@ struct ChatItemView: View {
var maxWidth: CGFloat = .infinity
@Binding var revealed: Bool
@Binding var allowMenu: Bool
@Binding var audioPlayer: AudioPlayer?
@Binding var playbackState: VoiceMessagePlaybackState
@Binding var playbackTime: TimeInterval?
init(
chat: Chat,
chatItem: ChatItem,
showMember: Bool = false,
maxWidth: CGFloat = .infinity,
revealed: Binding<Bool>,
allowMenu: Binding<Bool> = .constant(false),
audioPlayer: Binding<AudioPlayer?> = .constant(nil),
playbackState: Binding<VoiceMessagePlaybackState> = .constant(.noPlayback),
playbackTime: Binding<TimeInterval?> = .constant(nil)
allowMenu: Binding<Bool> = .constant(false)
) {
self.chat = chat
self.chatItem = chatItem
self.maxWidth = maxWidth
_revealed = revealed
_allowMenu = allowMenu
_audioPlayer = audioPlayer
_playbackState = playbackState
_playbackTime = playbackTime
}
var body: some View {
@@ -48,7 +40,7 @@ struct ChatItemView: View {
if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) {
EmojiItemView(chat: chat, chatItem: ci)
} else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu)
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: $allowMenu)
} else if ci.content.msgContent == nil {
ChatItemContentView(chat: chat, chatItem: chatItem, revealed: $revealed, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case
} else {
@@ -84,10 +76,7 @@ struct ChatItemView: View {
maxWidth: maxWidth,
imgWidth: adjustedMaxWidth,
videoWidth: adjustedMaxWidth,
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
allowMenu: $allowMenu
)
}
}
+384 -178
View File
@@ -15,6 +15,7 @@ private let memberImageSize: CGFloat = 34
struct ChatView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var im = ItemsModel.shared
@State var theme: AppTheme = buildTheme()
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
@@ -42,6 +43,9 @@ struct ChatView: View {
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
@State private var selectedChatItems: Set<Int64>? = nil
@State private var showDeleteSelectedMessages: Bool = false
@State private var allowToDeleteSelectedMessagesForAll: Bool = false
var body: some View {
if #available(iOS 16.0, *) {
@@ -79,24 +83,58 @@ struct ChatView: View {
floatingButtons(counts: floatingButtonModel.unreadChatItemCounts)
}
connectingText()
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!cInfo.sendMsgEnabled)
if selectedChatItems == nil {
ComposeView(
chat: chat,
composeState: $composeState,
keyboardVisible: $keyboardVisible
)
.disabled(!cInfo.sendMsgEnabled)
} else {
SelectedItemsBottomToolbar(
chatItems: ItemsModel.shared.reversedChatItems,
selectedChatItems: $selectedChatItems,
chatInfo: chat.chatInfo,
deleteItems: { forAll in
allowToDeleteSelectedMessagesForAll = forAll
showDeleteSelectedMessages = true
},
moderateItems: {
if case let .group(groupInfo) = chat.chatInfo {
showModerateSelectedMessagesAlert(groupInfo)
}
}
)
}
}
.navigationTitle(cInfo.chatViewName)
.background(theme.colors.background)
.navigationBarTitleDisplayMode(.inline)
.environmentObject(theme)
.confirmationDialog(selectedChatItems?.count == 1 ? "Delete message?" : "Delete \((selectedChatItems?.count ?? 0)) messages?", isPresented: $showDeleteSelectedMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
if let selected = selectedChatItems {
deleteMessages(chat, selected.sorted(), .cidmInternal, moderate: false, deletedSelectedMessages) }
}
if allowToDeleteSelectedMessagesForAll {
Button(broadcastDeleteButtonText(chat), role: .destructive) {
if let selected = selectedChatItems {
allowToDeleteSelectedMessagesForAll = false
deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: false, deletedSelectedMessages)
}
}
}
}
.onAppear {
loadChat(chat: chat)
initChatView()
selectedChatItems = nil
}
.onChange(of: chatModel.chatId) { cId in
showChatInfoSheet = false
stopAudioPlayer()
if let cId {
selectedChatItems = nil
if let c = chatModel.getChat(cId) {
chat = c
}
@@ -109,7 +147,7 @@ struct ChatView: View {
.onChange(of: revealedChatItem) { _ in
NotificationCenter.postReverseListNeedsLayout()
}
.onChange(of: chatModel.reversedChatItems) { reversedChatItems in
.onChange(of: im.reversedChatItems) { reversedChatItems in
if reversedChatItems.count <= loadItemsPerPage && filtered(reversedChatItems).count < 10 {
loadChatItems(chat.chatInfo)
}
@@ -117,12 +155,13 @@ struct ChatView: View {
.environmentObject(scrollModel)
.onDisappear {
VideoPlayerView.players.removeAll()
stopAudioPlayer()
if chatModel.chatId == cInfo.id && !presentationMode.wrappedValue.isPresented {
chatModel.chatId = nil
DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
if chatModel.chatId == nil {
chatModel.chatItemStatuses = [:]
chatModel.reversedChatItems = []
ItemsModel.shared.reversedChatItems = []
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
@@ -135,7 +174,9 @@ struct ChatView: View {
}
.toolbar {
ToolbarItem(placement: .principal) {
if case let .direct(contact) = cInfo {
if selectedChatItems != nil {
SelectedItemsTopToolbar(selectedChatItems: $selectedChatItems)
} else if case let .direct(contact) = cInfo {
Button {
Task {
do {
@@ -189,66 +230,76 @@ struct ChatView: View {
}
}
ToolbarItem(placement: .navigationBarTrailing) {
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
if selectedChatItems != nil {
Button {
withAnimation {
selectedChatItems = nil
}
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
} label: {
Text("Cancel")
}
} else {
switch cInfo {
case let .direct(contact):
HStack {
let callsPrefEnabled = contact.mergedPreferences.calls.enabled.forUser
if callsPrefEnabled {
if chatModel.activeCall == nil {
callButton(contact, .audio, imageName: "phone")
.disabled(!contact.ready || !contact.active)
} else if let call = chatModel.activeCall, call.contact.id == cInfo.id {
endCallButton(call)
}
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
}
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
Menu {
if callsPrefEnabled && chatModel.activeCall == nil {
Button {
CallController.shared.startCall(contact, .video)
} label: {
Label("Video call", systemImage: "video")
}
.disabled(!contact.ready || !contact.active)
}
searchButton()
ToggleNtfsButton(chat: chat)
.disabled(!contact.ready || !contact.active)
} label: {
Image(systemName: "ellipsis")
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
case let .group(groupInfo):
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
AddGroupMembersView(chat: chat, groupInfo: groupInfo)
}
}
}
Menu {
searchButton()
ToggleNtfsButton(chat: chat)
} label: {
Image(systemName: "ellipsis")
}
}
case .local:
searchButton()
default:
EmptyView()
}
case .local:
searchButton()
default:
EmptyView()
}
}
}
@@ -337,7 +388,7 @@ struct ChatView: View {
private func chatItemsList() -> some View {
let cInfo = chat.chatInfo
let mergedItems = filtered(chatModel.reversedChatItems)
let mergedItems = filtered(im.reversedChatItems)
return GeometryReader { g in
ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in
let voiceNoFrame = voiceWithoutFrame(ci)
@@ -370,7 +421,7 @@ struct ChatView: View {
loadChat(chat: c)
}
}
.onChange(of: chatModel.reversedChatItems) { _ in
.onChange(of: im.reversedChatItems) { _ in
floatingButtonModel.chatItemsChanged()
}
}
@@ -378,7 +429,7 @@ struct ChatView: View {
@ViewBuilder private func connectingText() -> some View {
if case let .direct(contact) = chat.chatInfo,
!contact.ready,
!contact.sndReady,
contact.active,
!contact.nextSendGrpInv {
Text("connecting…")
@@ -550,6 +601,33 @@ struct ChatView: View {
}
}
private func showModerateSelectedMessagesAlert(_ groupInfo: GroupInfo) {
guard let count = selectedChatItems?.count, count > 0 else { return }
AlertManager.shared.showAlert(Alert(
title: Text(count == 1 ? "Delete member message?" : "Delete \(count) messages of members?"),
message: Text(
groupInfo.fullGroupPreferences.fullDelete.on
? (count == 1 ? "The message will be deleted for all members." : "The messages will be deleted for all members.")
: (count == 1 ? "The message will be marked as moderated for all members." : "The messages will be marked as moderated for all members.")
),
primaryButton: .destructive(Text("Delete")) {
if let selected = selectedChatItems {
deleteMessages(chat, selected.sorted(), .cidmBroadcast, moderate: true, deletedSelectedMessages)
}
},
secondaryButton: .cancel()
))
}
private func deletedSelectedMessages() async {
await MainActor.run {
withAnimation {
selectedChatItems = nil
}
}
}
private func loadChatItems(_ cInfo: ChatInfo) {
Task {
if loadingItems || firstPage { return }
@@ -560,7 +638,7 @@ struct ChatView: View {
// Load additional items until the page is +50 large after merging
while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage {
let pagination: ChatPagination =
if let lastItem = reversedPage.last ?? chatModel.reversedChatItems.last {
if let lastItem = reversedPage.last ?? im.reversedChatItems.last {
.before(chatItemId: lastItem.id, count: loadItemsPerPage)
} else {
.last(count: loadItemsPerPage)
@@ -578,7 +656,7 @@ struct ChatView: View {
if reversedPage.count == 0 {
firstPage = true
} else {
chatModel.reversedChatItems.append(contentsOf: reversedPage)
im.reversedChatItems.append(contentsOf: reversedPage)
}
loadingItems = false
}
@@ -589,6 +667,11 @@ struct ChatView: View {
}
}
func stopAudioPlayer() {
VoiceItemState.chatView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.chatView = [:]
}
@ViewBuilder private func chatItemView(_ ci: ChatItem, _ maxWidth: CGFloat) -> some View {
ChatItemWithMenu(
chat: chat,
@@ -596,7 +679,8 @@ struct ChatView: View {
maxWidth: maxWidth,
composeState: $composeState,
selectedMember: $selectedMember,
revealedChatItem: $revealedChatItem
revealedChatItem: $revealedChatItem,
selectedChatItems: $selectedChatItems
)
}
@@ -618,11 +702,9 @@ struct ChatView: View {
@State private var chatItemInfo: ChatItemInfo?
@State private var showForwardingSheet: Bool = false
@State private var allowMenu: Bool = true
@Binding var selectedChatItems: Set<Int64>?
@State private var audioPlayer: AudioPlayer?
@State private var playbackState: VoiceMessagePlaybackState = .noPlayback
@State private var playbackTime: TimeInterval?
@State private var allowMenu: Bool = true
var revealed: Bool { chatItem == revealedChatItem }
@@ -631,35 +713,72 @@ struct ChatView: View {
let ciCategory = chatItem.mergeCategory
let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory)
let range = itemsRange(currIndex, prevHidden)
let im = ItemsModel.shared
Group {
if revealed, let range = range {
let items = Array(zip(Array(range), m.reversedChatItems[range]))
let items = Array(zip(Array(range), im.reversedChatItems[range]))
ForEach(items, id: \.1.viewId) { (i, ci) in
let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1]
let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1]
chatItemView(ci, nil, prev)
.overlay {
if let selected = selectedChatItems, ci.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(ci.id)
selectUnselectChatItem(select: !checked, ci)
}
}
}
}
} else {
chatItemView(chatItem, range, prevItem)
.overlay {
if let selected = selectedChatItems, chatItem.canBeDeletedForSelf {
Color.clear
.contentShape(Rectangle())
.onTapGesture {
let checked = selected.contains(chatItem.id)
selectUnselectChatItem(select: !checked, chatItem)
}
}
}
}
}
.onAppear {
markRead(
chatItems: range.flatMap { m.reversedChatItems[$0] }
?? [chatItem]
)
}
}
private func markRead(chatItems: Array<ChatItem>.SubSequence) {
let unreadItems = chatItems.filter { $0.isRcvNew }
if unreadItems.isEmpty { return }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
if m.chatId == chat.chatInfo.id {
Task {
for unreadItem in unreadItems {
await apiMarkChatItemRead(chat.chatInfo, unreadItem)
if let range {
if let items = unreadItems(range) {
waitToMarkRead {
for ci in items {
await apiMarkChatItemRead(chat.chatInfo, ci)
}
}
}
} else if chatItem.isRcvNew {
waitToMarkRead {
await apiMarkChatItemRead(chat.chatInfo, chatItem)
}
}
}
}
private func unreadItems(_ range: ClosedRange<Int>) -> [ChatItem]? {
let im = ItemsModel.shared
let items = range.compactMap { i in
if i >= 0 && i < im.reversedChatItems.count {
let ci = im.reversedChatItems[i]
return if ci.isRcvNew { ci } else { nil }
} else {
return nil
}
}
return if items.isEmpty { nil } else { items }
}
private func waitToMarkRead(_ op: @Sendable @escaping () async -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) {
if m.chatId == chat.chatInfo.id {
Task(operation: op)
}
}
}
@@ -668,11 +787,11 @@ struct ChatView: View {
if case let .groupRcv(member) = ci.chatDir,
case let .group(groupInfo) = chat.chatInfo {
let (prevMember, memCount): (GroupMember?, Int) =
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if let range = range {
m.getPrevHiddenMember(member, range)
} else {
(nil, 1)
}
if prevItem == nil || showMemberImage(member, prevItem) || prevMember != nil {
VStack(alignment: .leading, spacing: 4) {
if ci.content.showMemberName {
@@ -685,41 +804,64 @@ struct ChatView: View {
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, memberImageSize + 14)
.padding(.leading, memberImageSize + 14 + (selectedChatItems != nil && ci.canBeDeletedForSelf ? 12 + 24 : 0))
.padding(.top, 7)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
if m.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await m.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.trailing, 12)
}
HStack(alignment: .top, spacing: 8) {
ProfileImage(imageStr: member.memberProfile.image, size: memberImageSize, backgroundColor: theme.colors.background)
.onTapGesture {
if m.membersLoaded {
selectedMember = m.getGroupMember(member.groupMemberId)
} else {
Task {
await m.loadGroupMembers(groupInfo) {
selectedMember = m.getGroupMember(member.groupMemberId)
}
}
}
}
}
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
.appSheet(item: $selectedMember) { member in
GroupMemberInfoView(groupInfo: groupInfo, groupMember: member, navigation: true)
}
chatItemWithMenu(ci, range, maxWidth)
}
}
}
.padding(.bottom, 5)
.padding(.trailing)
.padding(.leading, 12)
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.bottom, 5)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
}
chatItemWithMenu(ci, range, maxWidth)
.padding(.trailing)
.padding(.leading, memberImageSize + 8 + 12)
}
.padding(.bottom, 5)
}
} else {
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
.padding(.bottom, 5)
HStack(alignment: .center, spacing: 0) {
if selectedChatItems != nil && ci.canBeDeletedForSelf {
if chat.chatInfo.chatType == .group {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading, 12)
} else {
SelectedChatItem(ciId: ci.id, selectedChatItems: $selectedChatItems)
.padding(.leading)
}
}
chatItemWithMenu(ci, range, maxWidth)
.padding(.horizontal)
}
.padding(.bottom, 5)
}
}
@@ -742,10 +884,7 @@ struct ChatView: View {
chatItem: ci,
maxWidth: maxWidth,
revealed: .constant(revealed),
allowMenu: $allowMenu,
audioPlayer: $audioPlayer,
playbackState: $playbackState,
playbackTime: $playbackTime
allowMenu: $allowMenu
)
.modifier(ChatItemClipped(ci))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
@@ -757,29 +896,21 @@ struct ChatView: View {
}
.confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessage(.cidmInternal)
deleteMessage(.cidmInternal, moderate: false)
}
if let di = deletingItem, di.meta.deletable && !di.localNote {
Button(broadcastDeleteButtonText, role: .destructive) {
deleteMessage(.cidmBroadcast)
Button(broadcastDeleteButtonText(chat), role: .destructive) {
deleteMessage(.cidmBroadcast, moderate: false)
}
}
}
.confirmationDialog(deleteMessagesTitle, isPresented: $showDeleteMessages, titleVisibility: .visible) {
Button("Delete for me", role: .destructive) {
deleteMessages()
deleteMessages(chat, deletingItems, moderate: false)
}
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.onDisappear {
if ci.content.msgContent?.isVoice == true {
allowMenu = true
audioPlayer?.stop()
playbackState = .noPlayback
playbackTime = TimeInterval(0)
}
}
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
@@ -884,7 +1015,7 @@ struct ChatView: View {
if !live || !ci.meta.isLive {
deleteButton(ci)
}
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo) {
if let (groupInfo, _) = ci.memberToModerate(chat.chatInfo), ci.chatDir != .groupSnd {
moderateButton(ci, groupInfo)
}
} else if ci.meta.itemDeleted != nil {
@@ -908,6 +1039,10 @@ struct ChatView: View {
} else {
EmptyView()
}
if selectedChatItems == nil && ci.canBeDeletedForSelf {
Divider()
selectButton(ci)
}
}
var replyButton: Button<some View> {
@@ -1080,6 +1215,21 @@ struct ChatView: View {
}
}
private func selectButton(_ ci: ChatItem) -> Button<some View> {
Button {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation {
selectUnselectChatItem(select: true, ci)
}
}
} label: {
Label(
NSLocalizedString("Select", comment: "chat item action"),
systemImage: "checkmark.circle"
)
}
}
private func viewInfoButton(_ ci: ChatItem) -> Button<some View> {
Button {
Task {
@@ -1149,7 +1299,7 @@ struct ChatView: View {
if let range = itemsRange(currIndex, prevHidden) {
var itemIds: [Int64] = []
for i in range {
itemIds.append(m.reversedChatItems[i].id)
itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
}
showDeleteMessages = true
deletingItems = itemIds
@@ -1190,7 +1340,7 @@ struct ChatView: View {
),
primaryButton: .destructive(Text("Delete")) {
deletingItem = ci
deleteMessage(.cidmBroadcast)
deleteMessage(.cidmBroadcast, moderate: true)
},
secondaryButton: .cancel()
))
@@ -1241,72 +1391,66 @@ struct ChatView: View {
}
}
private var broadcastDeleteButtonText: LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
var deleteMessagesTitle: LocalizedStringKey {
let n = deletingItems.count
return n == 1 ? "Delete message?" : "Delete \(n) messages?"
}
private func deleteMessages() {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
var deletedItems: [ChatItem] = []
for itemId in itemIds {
do {
let (di, _) = try await apiDeleteChatItem(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemId: itemId,
mode: .cidmInternal
)
deletedItems.append(di)
} catch {
logger.error("ChatView.deleteMessage error: \(error.localizedDescription)")
}
}
await MainActor.run {
for di in deletedItems {
m.removeChatItem(chatInfo, di)
}
private func selectUnselectChatItem(select: Bool, _ ci: ChatItem) {
selectedChatItems = selectedChatItems ?? []
var itemIds: [Int64] = []
if !revealed,
let currIndex = m.getChatItemIndex(ci),
let ciCategory = ci.mergeCategory {
let (prevHidden, _) = m.getPrevShownChatItem(currIndex, ciCategory)
if let range = itemsRange(currIndex, prevHidden) {
for i in range {
itemIds.append(ItemsModel.shared.reversedChatItems[i].id)
}
} else {
itemIds.append(ci.id)
}
} else {
itemIds.append(ci.id)
}
if select {
if let sel = selectedChatItems {
selectedChatItems = sel.union(itemIds)
}
} else {
itemIds.forEach { selectedChatItems?.remove($0) }
}
}
private func deleteMessage(_ mode: CIDeleteMode) {
private func deleteMessage(_ mode: CIDeleteMode, moderate: Bool) {
logger.debug("ChatView deleteMessage")
Task {
logger.debug("ChatView deleteMessage: in Task")
do {
if let di = deletingItem {
var deletedItem: ChatItem
var toItem: ChatItem?
if case .cidmBroadcast = mode,
let (groupInfo, groupMember) = di.memberToModerate(chat.chatInfo) {
(deletedItem, toItem) = try await apiDeleteMemberChatItem(
let r = if case .cidmBroadcast = mode,
moderate,
let (groupInfo, _) = di.memberToModerate(chat.chatInfo) {
try await apiDeleteMemberChatItems(
groupId: groupInfo.apiId,
groupMemberId: groupMember.groupMemberId,
itemId: di.id
itemIds: [di.id]
)
} else {
(deletedItem, toItem) = try await apiDeleteChatItem(
try await apiDeleteChatItems(
type: chat.chatInfo.chatType,
id: chat.chatInfo.apiId,
itemId: di.id,
itemIds: [di.id],
mode: mode
)
}
DispatchQueue.main.async {
deletingItem = nil
if let toItem = toItem {
_ = m.upsertChatItem(chat.chatInfo, toItem)
} else {
m.removeChatItem(chat.chatInfo, deletedItem)
if let itemDeletion = r.first {
await MainActor.run {
deletingItem = nil
if let toItem = itemDeletion.toChatItem {
_ = m.upsertChatItem(chat.chatInfo, toItem.chatItem)
} else {
m.removeChatItem(chat.chatInfo, itemDeletion.deletedChatItem.chatItem)
}
}
}
}
@@ -1315,6 +1459,68 @@ struct ChatView: View {
}
}
}
private struct SelectedChatItem: View {
@EnvironmentObject var theme: AppTheme
var ciId: Int64
@Binding var selectedChatItems: Set<Int64>?
@State var checked: Bool = false
var body: some View {
Image(systemName: checked ? "checkmark.circle.fill" : "circle")
.resizable()
.foregroundColor(checked ? theme.colors.primary : Color(uiColor: .tertiaryLabel))
.frame(width: 24, height: 24)
.onAppear {
checked = selectedChatItems?.contains(ciId) == true
}
.onChange(of: selectedChatItems) { selected in
checked = selected?.contains(ciId) == true
}
}
}
}
}
private func broadcastDeleteButtonText(_ chat: Chat) -> LocalizedStringKey {
chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone"
}
private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDeleteMode = .cidmInternal, moderate: Bool, _ onSuccess: @escaping () async -> Void = {}) {
let itemIds = deletingItems
if itemIds.count > 0 {
let chatInfo = chat.chatInfo
Task {
do {
let deletedItems = if case .cidmBroadcast = mode,
moderate,
case .group = chat.chatInfo {
try await apiDeleteMemberChatItems(
groupId: chatInfo.apiId,
itemIds: itemIds
)
} else {
try await apiDeleteChatItems(
type: chatInfo.chatType,
id: chatInfo.apiId,
itemIds: itemIds,
mode: mode
)
}
await MainActor.run {
for di in deletedItems {
if let toItem = di.toChatItem {
_ = ChatModel.shared.upsertChatItem(chat.chatInfo, toItem.chatItem)
} else {
ChatModel.shared.removeChatItem(chatInfo, di.deletedChatItem.chatItem)
}
}
}
await onSuccess()
} catch {
logger.error("ChatView.deleteMessages error: \(error.localizedDescription)")
}
}
}
}
@@ -1392,7 +1598,7 @@ struct ChatView_Previews: PreviewProvider {
static var previews: some View {
let chatModel = ChatModel()
chatModel.chatId = "@1"
chatModel.reversedChatItems = [
ItemsModel.shared.reversedChatItems = [
ChatItem.getSample(1, .directSnd, .now, "hello"),
ChatItem.getSample(2, .directRcv, .now, "hi"),
ChatItem.getSample(3, .directRcv, .now, "hi there"),
@@ -10,35 +10,6 @@ import SwiftUI
import LinkPresentation
import SimpleXChat
func getLinkPreview(url: URL, cb: @escaping (LinkPreview?) -> Void) {
logger.debug("getLinkMetadata: fetching URL preview")
LPMetadataProvider().startFetchingMetadata(for: url){ metadata, error in
if let e = error {
logger.error("Error retrieving link metadata: \(e.localizedDescription)")
}
if let metadata = metadata,
let imageProvider = metadata.imageProvider,
imageProvider.canLoadObject(ofClass: UIImage.self) {
imageProvider.loadObject(ofClass: UIImage.self){ object, error in
var linkPreview: LinkPreview? = nil
if let error = error {
logger.error("Couldn't load image preview from link metadata with error: \(error.localizedDescription)")
} else {
if let image = object as? UIImage,
let resized = resizeImageToStrSize(image, maxDataSize: 14000),
let title = metadata.title,
let uri = metadata.originalURL {
linkPreview = LinkPreview(uri: uri, title: title, image: resized)
}
}
cb(linkPreview)
}
} else {
cb(nil)
}
}
}
struct ComposeLinkView: View {
@EnvironmentObject var theme: AppTheme
let linkPreview: LinkPreview?
@@ -1120,10 +1120,6 @@ struct ComposeView: View {
}
}
func parsedMsgHasSimplexLink(_ parsedMsg: [FormattedText]) -> Bool {
parsedMsg.contains(where: { ft in ft.format?.isSimplexLink ?? false })
}
struct ComposeView_Previews: PreviewProvider {
static var previews: some View {
let chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: [])
@@ -67,7 +67,6 @@ struct SendMessageView: View {
.fixedSize(horizontal: false, vertical: true)
}
}
if progressByTimeout {
ProgressView()
.scaleEffect(1.4)
@@ -87,7 +86,7 @@ struct SendMessageView: View {
.padding(.vertical, 1)
.background(theme.colors.background)
.clipShape(composeShape)
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
.overlay(composeShape.strokeBorder(.secondary, lineWidth: 0.5).opacity(0.7))
}
.onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in
@@ -258,6 +257,9 @@ struct SendMessageView: View {
var body: some View {
Button(action: {}) {
Image(systemName: "mic.fill")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.primary)
}
.disabled(disabled)
@@ -311,6 +313,9 @@ struct SendMessageView: View {
}
} label: {
Image(systemName: "mic")
.resizable()
.scaledToFit()
.frame(width: 20, height: 20)
.foregroundColor(theme.colors.secondary)
}
.disabled(composeState.inProgress)
@@ -35,7 +35,7 @@ struct AddGroupMembersViewCommon: View {
private enum AddGroupMembersAlert: Identifiable {
case prohibitedToInviteIncognito
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -122,7 +122,7 @@ struct AddGroupMembersViewCommon: View {
message: Text("You're trying to invite contact with whom you've shared an incognito profile to the group in which you're using your main profile")
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
return mkAlert(title: title, message: error)
}
}
.onChange(of: selectedContacts) { _ in
@@ -40,7 +40,7 @@ struct GroupChatInfoView: View {
case blockForAllAlert(mem: GroupMember)
case unblockForAllAlert(mem: GroupMember)
case removeMemberAlert(mem: GroupMember)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -158,7 +158,7 @@ struct GroupChatInfoView: View {
case let .blockForAllAlert(mem): return blockForAllAlert(groupInfo, mem)
case let .unblockForAllAlert(mem): return unblockForAllAlert(groupInfo, mem)
case let .removeMemberAlert(mem): return removeMemberAlert(mem)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.onAppear {
@@ -22,7 +22,7 @@ struct GroupLinkView: View {
private enum GroupLinkAlert: Identifiable {
case deleteLink
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -113,7 +113,7 @@ struct GroupLinkView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
return mkAlert(title: title, message: error)
}
}
.onChange(of: groupLinkMemberRole) { _ in
@@ -37,7 +37,7 @@ struct GroupMemberInfoView: View {
case syncConnectionForceAlert
case planAndConnectAlert(alert: PlanAndConnectAlert)
case queueInfo(info: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -237,7 +237,7 @@ struct GroupMemberInfoView: View {
case .syncConnectionForceAlert: return syncConnectionForceAlert({ syncMemberConnection(force: true) })
case let .planAndConnectAlert(alert): return planAndConnectAlert(alert, dismiss: true)
case let .queueInfo(info): return queueInfoAlert(info)
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.actionSheet(item: $sheet) { s in planAndConnectActionSheet(s, dismiss: true) }
+2 -1
View File
@@ -129,8 +129,9 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
from: nil,
for: nil
)
NotificationCenter.default.post(name: .chatViewWillBeginScrolling, object: nil)
}
/// Scrolls up
func scrollToNextPage() {
tableView.setContentOffset(
@@ -0,0 +1,130 @@
//
// SelectableChatItemToolbars.swift
// SimpleX (iOS)
//
// Created by Stanislav Dmitrenko on 30.07.2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct SelectedItemsTopToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
@Binding var selectedChatItems: Set<Int64>?
var body: some View {
let count = selectedChatItems?.count ?? 0
return Text(count == 0 ? "Nothing selected" : "Selected \(count)").font(.headline)
.foregroundColor(theme.colors.onBackground)
.frame(width: 220)
}
}
struct SelectedItemsBottomToolbar: View {
@Environment(\.colorScheme) var colorScheme
@EnvironmentObject var theme: AppTheme
let chatItems: [ChatItem]
@Binding var selectedChatItems: Set<Int64>?
var chatInfo: ChatInfo
// Bool - delete for everyone is possible
var deleteItems: (Bool) -> Void
var moderateItems: () -> Void
//var shareItems: () -> Void
@State var deleteEnabled: Bool = false
@State var deleteForEveryoneEnabled: Bool = false
@State var canModerate: Bool = false
@State var moderateEnabled: Bool = false
@State var allButtonsDisabled = false
var body: some View {
VStack(spacing: 0) {
Divider()
HStack(alignment: .center) {
Button {
deleteItems(deleteForEveryoneEnabled)
} label: {
Image(systemName: "trash")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!deleteEnabled || allButtonsDisabled ? theme.colors.secondary: .red)
}
.disabled(!deleteEnabled || allButtonsDisabled)
Spacer()
Button {
moderateItems()
} label: {
Image(systemName: "flag")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(!moderateEnabled || allButtonsDisabled ? theme.colors.secondary : .red)
}
.disabled(!moderateEnabled || allButtonsDisabled)
.opacity(canModerate ? 1 : 0)
Spacer()
Button {
//shareItems()
} label: {
Image(systemName: "square.and.arrow.up")
.resizable()
.frame(width: 20, height: 20, alignment: .center)
.foregroundColor(allButtonsDisabled ? theme.colors.secondary : theme.colors.primary)
}
.disabled(allButtonsDisabled)
.opacity(0)
}
.frame(maxHeight: .infinity)
.padding([.leading, .trailing], 12)
}
.onAppear {
recheckItems(chatInfo, chatItems, selectedChatItems)
}
.onChange(of: chatInfo) { info in
recheckItems(info, chatItems, selectedChatItems)
}
.onChange(of: chatItems) { items in
recheckItems(chatInfo, items, selectedChatItems)
}
.onChange(of: selectedChatItems) { selected in
recheckItems(chatInfo, chatItems, selected)
}
.frame(height: 55.5)
.background(.thinMaterial)
}
private func recheckItems(_ chatInfo: ChatInfo, _ chatItems: [ChatItem], _ selectedItems: Set<Int64>?) {
let count = selectedItems?.count ?? 0
allButtonsDisabled = count == 0 || count > 20
canModerate = possibleToModerate(chatInfo)
if let selected = selectedItems {
(deleteEnabled, deleteForEveryoneEnabled, moderateEnabled, _, selectedChatItems) = chatItems.reduce((true, true, true, true, [])) { (r, ci) in
if selected.contains(ci.id) {
var (de, dee, me, onlyOwnGroupItems, sel) = r
de = de && ci.canBeDeletedForSelf
dee = dee && ci.meta.deletable && !ci.localNote
onlyOwnGroupItems = onlyOwnGroupItems && ci.chatDir == .groupSnd
me = me && !onlyOwnGroupItems && ci.content.msgContent != nil && ci.memberToModerate(chatInfo) != nil
sel.insert(ci.id) // we are collecting new selected items here to account for any changes in chat items list
return (de, dee, me, onlyOwnGroupItems, sel)
} else {
return r
}
}
}
}
private func possibleToModerate(_ chatInfo: ChatInfo) -> Bool {
return switch chatInfo {
case let .group(groupInfo):
groupInfo.membership.memberRole >= .admin
default: false
}
}
}
@@ -9,25 +9,41 @@
import SwiftUI
import SimpleXChat
private let rowHeights: [DynamicTypeSize: CGFloat] = [
.xSmall: 68,
.small: 72,
.medium: 76,
.large: 80,
.xLarge: 88,
.xxLarge: 94,
.xxxLarge: 104,
.accessibility1: 90,
.accessibility2: 100,
.accessibility3: 120,
.accessibility4: 130,
.accessibility5: 140
typealias DynamicSizes = (
rowHeight: CGFloat,
profileImageSize: CGFloat,
mediaSize: CGFloat,
incognitoSize: CGFloat,
chatInfoSize: CGFloat,
unreadCorner: CGFloat,
unreadPadding: CGFloat
)
private let dynamicSizes: [DynamicTypeSize: DynamicSizes] = [
.xSmall: (68, 55, 33, 22, 18, 9, 3),
.small: (72, 57, 34, 22, 18, 9, 3),
.medium: (76, 60, 36, 22, 18, 10, 4),
.large: (80, 63, 38, 24, 20, 10, 4),
.xLarge: (88, 67, 41, 24, 20, 10, 4),
.xxLarge: (100, 71, 44, 27, 22, 11, 4),
.xxxLarge: (110, 75, 48, 30, 24, 12, 5),
.accessibility1: (110, 75, 48, 30, 24, 12, 5),
.accessibility2: (114, 75, 48, 30, 24, 12, 5),
.accessibility3: (124, 75, 48, 30, 24, 12, 5),
.accessibility4: (134, 75, 48, 30, 24, 12, 5),
.accessibility5: (144, 75, 48, 30, 24, 12, 5)
]
private let defaultDynamicSizes: DynamicSizes = dynamicSizes[.large]!
func dynamicSize(_ font: DynamicTypeSize) -> DynamicSizes {
dynamicSizes[font] ?? defaultDynamicSizes
}
struct ChatListNavLink: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@ObservedObject var chat: Chat
@State private var showContactRequestDialog = false
@State private var showJoinGroupDialog = false
@@ -38,6 +54,8 @@ struct ChatListNavLink: View {
@State private var inProgress = false
@State private var progressByTimeout = false
var dynamicRowHeight: CGFloat { dynamicSizes[userFont]?.rowHeight ?? 80 }
var body: some View {
Group {
switch chat.chatInfo {
@@ -70,7 +88,7 @@ struct ChatListNavLink: View {
Group {
if contact.activeConn == nil && contact.profile.contactLink != nil {
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button {
showDeleteContactActionSheet = true
@@ -100,7 +118,7 @@ struct ChatListNavLink: View {
clearChatButton()
}
Button {
if contact.ready || !contact.active {
if contact.sndReady || !contact.active {
showDeleteContactActionSheet = true
} else {
AlertManager.shared.showAlert(deletePendingContactAlert(chat, contact))
@@ -110,11 +128,11 @@ struct ChatListNavLink: View {
}
.tint(.red)
}
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
}
}
.actionSheet(isPresented: $showDeleteContactActionSheet) {
if contact.ready && contact.active {
if contact.sndReady && contact.active {
return ActionSheet(
title: Text("Delete contact?\nThis cannot be undone!"),
buttons: [
@@ -139,7 +157,7 @@ struct ChatListNavLink: View {
switch (groupInfo.membership.memberStatus) {
case .memInvited:
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
if groupInfo.canDelete {
@@ -159,7 +177,7 @@ struct ChatListNavLink: View {
.disabled(inProgress)
case .memAccepted:
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
}
@@ -178,7 +196,7 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
)
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
toggleFavoriteButton()
@@ -205,7 +223,7 @@ struct ChatListNavLink: View {
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !noteFolder.ready
)
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
}
@@ -321,7 +339,7 @@ struct ChatListNavLink: View {
}
.tint(.red)
}
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.onTapGesture { showContactRequestDialog = true }
.confirmationDialog("Accept connection request?", isPresented: $showContactRequestDialog, titleVisibility: .visible) {
Button("Accept") { Task { await acceptContactRequest(incognito: false, contactRequest: contactRequest) } }
@@ -349,7 +367,7 @@ struct ChatListNavLink: View {
}
.tint(theme.colors.primary)
}
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.appSheet(isPresented: $showContactConnectionInfo) {
Group {
if case let .contactConnection(contactConnection) = chat.chatInfo {
@@ -469,7 +487,7 @@ struct ChatListNavLink: View {
Text("invalid chat data")
.foregroundColor(.red)
.padding(4)
.frame(height: rowHeights[dynamicTypeSize])
.frame(height: dynamicRowHeight)
.onTapGesture { showInvalidJSON = true }
.appSheet(isPresented: $showInvalidJSON) {
invalidJSONView(json)
@@ -21,6 +21,7 @@ struct ChatListView: View {
@State private var newChatMenuOption: NewChatMenuOption? = nil
@State private var userPickerVisible = false
@State private var showConnectDesktop = false
@AppStorage(DEFAULT_SHOW_UNREAD_AND_FAVORITES) private var showUnreadAndFavorites = false
var body: some View {
@@ -162,6 +163,10 @@ struct ChatListView: View {
chatModel.chatToTop = nil
chatModel.popChat(chatId)
}
stopAudioPlayer()
}
.onChange(of: chatModel.currentUser?.userId) { _ in
stopAudioPlayer()
}
if cs.isEmpty && !chatModel.chats.isEmpty {
Text("No filtered chats").foregroundColor(theme.colors.secondary)
@@ -217,6 +222,11 @@ struct ChatListView: View {
}
}
func stopAudioPlayer() {
VoiceItemState.smallView.values.forEach { $0.audioPlayer?.stop() }
VoiceItemState.smallView = [:]
}
private func filteredChats() -> [Chat] {
if let linkChatId = searchChatFilteredBySimplexLink {
return chatModel.chats.filter { $0.id == linkChatId }
@@ -266,9 +276,9 @@ struct ChatListView: View {
}
struct SubsStatusIndicator: View {
@State private var serversSummary: PresentedServersSummary?
@State private var subs: SMPServerSubs = SMPServerSubs.newSMPServerSubs
@State private var hasSess: Bool = false
@State private var timer: Timer? = nil
@State private var timerCounter = 0
@State private var showServersSummary = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@@ -277,12 +287,10 @@ struct SubsStatusIndicator: View {
Button {
showServersSummary = true
} label: {
let subs = serversSummary?.allUsersSMP.smpTotals.subs ?? SMPServerSubs.newSMPServerSubs
let sess = serversSummary?.allUsersSMP.smpTotals.sessions ?? ServerSessions.newServerSessions
HStack(spacing: 4) {
SubscriptionStatusIndicatorView(subs: subs, sess: sess)
SubscriptionStatusIndicatorView(subs: subs, hasSess: hasSess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, sess: sess)
SubscriptionStatusPercentageView(subs: subs, hasSess: hasSess)
}
}
}
@@ -293,14 +301,14 @@ struct SubsStatusIndicator: View {
stopTimer()
}
.sheet(isPresented: $showServersSummary) {
ServersSummaryView(serversSummary: $serversSummary)
ServersSummaryView()
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if AppChatState.shared.value == .active {
getServersSummary()
getSubsTotal()
}
}
}
@@ -310,11 +318,11 @@ struct SubsStatusIndicator: View {
timer = nil
}
private func getServersSummary() {
private func getSubsTotal() {
do {
serversSummary = try getAgentServersSummary()
(subs, hasSess) = try getAgentSubsTotal()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
logger.error("getSubsTotal error: \(responseError(error))")
}
}
}
@@ -12,18 +12,24 @@ import SimpleXChat
struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool
@State var deleting: Bool = false
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@State private var activeContentPreview: ActiveContentPreview? = nil
@State private var showFullscreenGallery: Bool = false
@AppStorage(DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS) private var showChatPreviews = true
var dynamicMediaSize: CGFloat { dynamicSize(userFont).mediaSize }
var dynamicChatInfoSize: CGFloat { dynamicSize(userFont).chatInfoSize }
var body: some View {
let cItem = chat.chatItems.last
return HStack(spacing: 8) {
ZStack(alignment: .bottomTrailing) {
ChatInfoImage(chat: chat, size: 63)
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
chatPreviewImageOverlayIcon()
.padding([.bottom, .trailing], 1)
}
@@ -43,11 +49,38 @@ struct ChatPreviewView: View {
.padding(.horizontal, 8)
ZStack(alignment: .topTrailing) {
chatMessagePreview(cItem)
let chat = activeContentPreview?.chat ?? chat
let ci = activeContentPreview?.ci ?? chat.chatItems.last
let mc = ci?.content.msgContent
HStack(alignment: .top) {
let deleted = ci?.isDeletedContent == true || ci?.meta.itemDeleted != nil
let showContentPreview = (showChatPreviews && chatModel.draftChatId != chat.id && !deleted) || activeContentPreview != nil
if let ci, showContentPreview {
chatItemContentPreview(chat, ci)
}
let mcIsVoice = switch mc { case .voice: true; default: false }
if !mcIsVoice || !showContentPreview || mc?.text != "" || chatModel.draftChatId == chat.id {
let hasFilePreview = if case .file = mc { true } else { false }
chatMessagePreview(cItem, hasFilePreview)
} else {
Spacer()
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
.onChange(of: chatModel.stopPreviousRecPlay?.path) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: activeContentPreview) { _ in
checkActiveContentPreview(chat, ci, mc)
}
.onChange(of: showFullscreenGallery) { _ in
checkActiveContentPreview(chat, ci, mc)
}
chatStatusImage()
.padding(.top, 26)
.padding(.top, dynamicChatInfoSize * 1.44)
.frame(maxWidth: .infinity, alignment: .trailing)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.trailing, 8)
Spacer()
@@ -57,6 +90,33 @@ struct ChatPreviewView: View {
.padding(.bottom, -8)
.onChange(of: chatModel.deletedChats.contains(chat.chatInfo.id)) { contains in
deleting = contains
// Stop voice when deleting the chat
if contains, let ci = activeContentPreview?.ci {
VoiceItemState.stopVoiceInSmallView(chat.chatInfo, ci)
}
}
func checkActiveContentPreview(_ chat: Chat, _ ci: ChatItem?, _ mc: MsgContent?) {
let playing = chatModel.stopPreviousRecPlay
if case .voice = activeContentPreview?.mc, playing == nil {
activeContentPreview = nil
} else if activeContentPreview == nil {
if case .image = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .video = mc, let ci, let mc, showFullscreenGallery {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
if case .voice = mc, let ci, let mc, let fileSource = ci.file?.fileSource, playing?.path.hasSuffix(fileSource.filePath) == true {
activeContentPreview = ActiveContentPreview(chat: chat, ci: ci, mc: mc)
}
} else if case .voice = activeContentPreview?.mc {
if let playing, let fileSource = ci?.file?.fileSource, !playing.path.hasSuffix(fileSource.filePath) {
activeContentPreview = nil
}
} else if !showFullscreenGallery {
activeContentPreview = nil
}
}
}
@@ -113,39 +173,50 @@ struct ChatPreviewView: View {
.kerning(-2)
}
private func chatPreviewLayout(_ text: Text, draft: Bool = false) -> some View {
private func chatPreviewLayout(_ text: Text?, draft: Bool = false, _ hasFilePreview: Bool = false) -> some View {
ZStack(alignment: .topTrailing) {
let t = text
.lineLimit(2)
.lineLimit(userFont <= .xxxLarge ? 2 : 1)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .topLeading)
.padding(.leading, 8)
.padding(.trailing, 36)
.padding(.leading, hasFilePreview ? 0 : 8)
.padding(.trailing, hasFilePreview ? 38 : 36)
.offset(x: hasFilePreview ? -2 : 0)
.fixedSize(horizontal: false, vertical: true)
if !showChatPreviews && !draft {
t.privacySensitive(true).redacted(reason: .privacy)
} else {
t
}
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(.caption)
.foregroundColor(.white)
.padding(.horizontal, 4)
.frame(minWidth: 18, minHeight: 18)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(10)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: 18, height: 18)
.padding(.trailing, 1)
.foregroundColor(.secondary.opacity(0.65))
}
chatInfoIcon(chat).frame(minWidth: 37, alignment: .trailing)
}
}
@ViewBuilder private func chatInfoIcon(_ chat: Chat) -> some View {
let s = chat.chatStats
if s.unreadCount > 0 || s.unreadChat {
unreadCountText(s.unreadCount)
.font(userFont <= .xxxLarge ? .caption : .caption2)
.foregroundColor(.white)
.padding(.horizontal, dynamicSize(userFont).unreadPadding)
.frame(minWidth: dynamicChatInfoSize, minHeight: dynamicChatInfoSize)
.background(chat.chatInfo.ntfsEnabled || chat.chatInfo.chatType == .local ? theme.colors.primary : theme.colors.secondary)
.cornerRadius(dynamicSize(userFont).unreadCorner)
} else if !chat.chatInfo.ntfsEnabled && chat.chatInfo.chatType != .local {
Image(systemName: "speaker.slash.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
} else if chat.chatInfo.chatSettings?.favorite ?? false {
Image(systemName: "star.fill")
.resizable()
.scaledToFill()
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.padding(.trailing, 1)
.foregroundColor(theme.colors.secondary.opacity(0.65))
} else {
Color.clear.frame(width: 0)
}
}
@@ -172,7 +243,7 @@ struct ChatPreviewView: View {
func chatItemPreview(_ cItem: ChatItem) -> Text {
let itemText = cItem.meta.itemDeleted == nil ? cItem.text : markedDeletedText()
let itemFormattedText = cItem.meta.itemDeleted == nil ? cItem.formattedText : nil
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: attachment(), preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
return messageText(itemText, itemFormattedText, cItem.memberDisplayName, icon: nil, preview: true, showSecrets: false, secondaryColor: theme.colors.secondary)
// 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
@@ -196,18 +267,18 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?) -> some View {
@ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View {
if chatModel.draftChatId == chat.id, let draft = chatModel.draft {
chatPreviewLayout(messageDraft(draft), draft: true)
chatPreviewLayout(messageDraft(draft), draft: true, hasFilePreview)
} else if let cItem = cItem {
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem))
chatPreviewLayout(itemStatusMark(cItem) + chatItemPreview(cItem), hasFilePreview)
} else {
switch (chat.chatInfo) {
case let .direct(contact):
if contact.activeConn == nil && contact.profile.contactLink != nil {
chatPreviewInfoText("Tap to Connect")
.foregroundColor(theme.colors.primary)
} else if !contact.ready && contact.activeConn != nil {
} else if !contact.sndReady && contact.activeConn != nil {
if contact.nextSendGrpInv {
chatPreviewInfoText("send direct message")
} else if contact.active {
@@ -225,6 +296,54 @@ struct ChatPreviewView: View {
}
}
@ViewBuilder func chatItemContentPreview(_ chat: Chat, _ ci: ChatItem) -> some View {
let mc = ci.content.msgContent
switch mc {
case let .link(_, preview):
smallContentPreview(size: dynamicMediaSize) {
ZStack(alignment: .topTrailing) {
Image(uiImage: UIImage(base64Encoded: preview.image) ?? UIImage(systemName: "arrow.up.right")!)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: dynamicMediaSize, height: dynamicMediaSize)
ZStack {
Image(systemName: "arrow.up.right")
.resizable()
.foregroundColor(Color.white)
.font(.system(size: 15, weight: .black))
.frame(width: 8, height: 8)
}
.frame(width: 16, height: 16)
.background(Color.black.opacity(0.25))
.cornerRadius(8)
}
.onTapGesture {
UIApplication.shared.open(preview.uri)
}
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: UIImage(base64Encoded: image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>())
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: UIImage(base64Encoded: image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
.environmentObject(ReverseListScrollModel<ChatItem>())
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
CIVoiceView(chat: chat, chatItem: ci, recordingFile: ci.file, duration: duration, allowMenu: Binding.constant(true), smallViewSize: dynamicMediaSize)
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
}
default: EmptyView()
}
}
@ViewBuilder private func groupInvitationPreviewText(_ groupInfo: GroupInfo) -> some View {
groupInfo.membership.memberIncognito
? chatPreviewInfoText("join as \(groupInfo.membership.memberProfile.displayName)")
@@ -253,51 +372,87 @@ struct ChatPreviewView: View {
}
@ViewBuilder private func chatStatusImage() -> some View {
let size = dynamicSize(userFont).incognitoSize
switch chat.chatInfo {
case let .direct(contact):
if contact.active && contact.activeConn != nil {
switch (chatModel.contactNetworkStatus(contact)) {
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
case .connected: incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
case .error:
Image(systemName: "exclamationmark.circle")
.resizable()
.scaledToFit()
.frame(width: 17, height: 17)
.frame(width: dynamicChatInfoSize, height: dynamicChatInfoSize)
.foregroundColor(theme.colors.secondary)
default:
ProgressView()
}
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
case .group:
if progressByTimeout {
ProgressView()
} else {
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
default:
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary)
incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size)
}
}
}
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color) -> some View {
@ViewBuilder func incognitoIcon(_ incognito: Bool, _ secondaryColor: Color, size: CGFloat) -> some View {
if incognito {
Image(systemName: "theatermasks")
.resizable()
.scaledToFit()
.frame(width: 22, height: 22)
.frame(width: size, height: size)
.foregroundColor(secondaryColor)
} else {
EmptyView()
}
}
func smallContentPreview(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.cornerRadius(8)
.overlay(RoundedRectangle(cornerSize: CGSize(width: 8, height: 8))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true))
.padding(.vertical, size / 6)
.padding(.leading, 3)
.offset(x: 6)
}
func smallContentPreviewVoice(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(height: voiceMessageSizeBasedOnSquareSize(size))
.padding(.vertical, size / 6)
.padding(.leading, 8)
}
func smallContentPreviewFile(size: CGFloat, _ view: @escaping () -> some View) -> some View {
view()
.frame(width: size, height: size)
.padding(.vertical, size / 7)
.padding(.leading, 5)
}
func unreadCountText(_ n: Int) -> Text {
Text(n > 999 ? "\(n / 1000)k" : n > 0 ? "\(n)" : "")
}
private struct ActiveContentPreview: Equatable {
var chat: Chat
var ci: ChatItem
var mc: MsgContent
static func == (lhs: ActiveContentPreview, rhs: ActiveContentPreview) -> Bool {
lhs.chat.id == rhs.chat.id && lhs.ci.id == rhs.ci.id && lhs.mc == rhs.mc
}
}
struct ChatPreviewView_Previews: PreviewProvider {
static var previews: some View {
Group {
@@ -21,7 +21,7 @@ struct ContactConnectionInfo: View {
enum CCInfoAlert: Identifiable {
case deleteInvitationAlert
case error(title: LocalizedStringKey, error: LocalizedStringKey)
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -102,7 +102,7 @@ struct ContactConnectionInfo: View {
} success: {
dismiss()
}
case let .error(title, error): return Alert(title: Text(title), message: Text(error))
case let .error(title, error): return mkAlert(title: title, message: error)
}
}
.onAppear {
@@ -13,6 +13,7 @@ struct ContactConnectionView: View {
@EnvironmentObject var m: ChatModel
@ObservedObject var chat: Chat
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
@State private var localAlias = ""
@FocusState private var aliasTextFieldFocused: Bool
@State private var showContactConnectionInfo = false
@@ -62,7 +63,7 @@ struct ContactConnectionView: View {
ZStack(alignment: .topTrailing) {
Text(contactConnection.description)
.frame(maxWidth: .infinity, alignment: .leading)
incognitoIcon(contactConnection.incognito, theme.colors.secondary)
incognitoIcon(contactConnection.incognito, theme.colors.secondary, size: dynamicSize(userFont).incognitoSize)
.padding(.top, 26)
.frame(maxWidth: .infinity, alignment: .trailing)
}
@@ -12,12 +12,13 @@ import SimpleXChat
struct ContactRequestView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@Environment(\.dynamicTypeSize) private var userFont: DynamicTypeSize
var contactRequest: UserContactRequest
@ObservedObject var chat: Chat
var body: some View {
HStack(spacing: 8) {
ChatInfoImage(chat: chat, size: 63)
ChatInfoImage(chat: chat, size: dynamicSize(userFont).profileImageSize)
.padding(.leading, 4)
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
@@ -12,11 +12,12 @@ import SimpleXChat
struct ServersSummaryView: View {
@EnvironmentObject var m: ChatModel
@EnvironmentObject var theme: AppTheme
@Binding var serversSummary: PresentedServersSummary?
@State private var serversSummary: PresentedServersSummary? = nil
@State private var selectedUserCategory: PresentedUserCategory = .allUsers
@State private var selectedServerType: PresentedServerType = .smp
@State private var selectedSMPServer: String? = nil
@State private var selectedXFTPServer: String? = nil
@State private var timer: Timer? = nil
@State private var alert: SomeAlert?
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@@ -47,10 +48,36 @@ struct ServersSummaryView: View {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count == 1 {
selectedUserCategory = .currentUser
}
getServersSummary()
startTimer()
}
.onDisappear {
stopTimer()
}
.alert(item: $alert) { $0.alert }
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
if AppChatState.shared.value == .active {
getServersSummary()
}
}
}
private func getServersSummary() {
do {
serversSummary = try getAgentServersSummary()
} catch let error {
logger.error("getAgentServersSummary error: \(responseError(error))")
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func shareButton() -> some View {
Button {
if let serversSummary = serversSummary {
@@ -75,8 +102,8 @@ struct ServersSummaryView: View {
Group {
if m.users.filter({ u in u.user.activeUser || !u.user.hidden }).count > 1 {
Picker("User selection", selection: $selectedUserCategory) {
Text("All users").tag(PresentedUserCategory.allUsers)
Text("Current user").tag(PresentedUserCategory.currentUser)
Text("All profiles").tag(PresentedUserCategory.allUsers)
Text("Current profile").tag(PresentedUserCategory.currentUser)
}
.pickerStyle(.segmented)
}
@@ -176,12 +203,13 @@ struct ServersSummaryView: View {
Section {
infoRow("Active connections", numOrDash(totals.subs.ssActive))
infoRow("Total", numOrDash(totals.subs.total))
Toggle("Show percentage", isOn: $showSubscriptionPercentage)
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: totals.subs, sess: totals.sessions)
SubscriptionStatusIndicatorView(subs: totals.subs, hasSess: totals.sessions.hasSess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: totals.subs, sess: totals.sessions)
SubscriptionStatusPercentageView(subs: totals.subs, hasSess: totals.sessions.hasSess)
}
}
}
@@ -259,9 +287,9 @@ struct ServersSummaryView: View {
if let subs = srvSumm.subs {
Spacer()
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, sess: srvSumm.sessionsOrNew)
SubscriptionStatusPercentageView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
}
SubscriptionStatusIndicatorView(subs: subs, sess: srvSumm.sessionsOrNew)
SubscriptionStatusIndicatorView(subs: subs, hasSess: srvSumm.sessionsOrNew.hasSess)
} else if let sess = srvSumm.sessions {
Spacer()
Image(systemName: "arrow.up.circle")
@@ -355,6 +383,7 @@ struct ServersSummaryView: View {
Task {
do {
try await resetAgentServersStats()
getServersSummary()
} catch let error {
alert = SomeAlert(
alert: mkAlert(
@@ -379,11 +408,11 @@ struct ServersSummaryView: View {
struct SubscriptionStatusIndicatorView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var sess: ServerSessions
var hasSess: Bool
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
let (color, variableValue, opacity, _) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
if #available(iOS 16.0, *) {
Image(systemName: "dot.radiowaves.up.forward", variableValue: variableValue)
.foregroundColor(color)
@@ -397,18 +426,18 @@ struct SubscriptionStatusIndicatorView: View {
struct SubscriptionStatusPercentageView: View {
@EnvironmentObject var m: ChatModel
var subs: SMPServerSubs
var sess: ServerSessions
var hasSess: Bool
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, hasSess)
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
}
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ sess: ServerSessions) -> (Color, Double, Double, Double) {
func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHosts, _ subs: SMPServerSubs, _ hasSess: Bool) -> (Color, Double, Double, Double) {
func roundedToQuarter(_ n: Double) -> Double {
n >= 1 ? 1
: n <= 0 ? 0
@@ -423,12 +452,12 @@ func subscriptionStatusColorAndPercentage(_ online: Bool, _ onionHosts: OnionHos
? (
subs.ssActive == 0
? (
sess.ssConnected == 0 ? noConnColorAndPercent : (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
hasSess ? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) : noConnColorAndPercent
)
: ( // ssActive > 0
sess.ssConnected == 0
? (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
: (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
hasSess
? (activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
: (.orange, activeSubsRounded, subs.shareOfActive, subs.shareOfActive) // This would mean implementation error
)
)
: noConnColorAndPercent
@@ -481,9 +510,9 @@ struct SMPServerSummaryView: View {
} header: {
HStack {
Text("Message reception")
SubscriptionStatusIndicatorView(subs: subs, sess: summary.sessionsOrNew)
SubscriptionStatusIndicatorView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
if showSubscriptionPercentage {
SubscriptionStatusPercentageView(subs: subs, sess: summary.sessionsOrNew)
SubscriptionStatusPercentageView(subs: subs, hasSess: summary.sessionsOrNew.hasSess)
}
}
}
@@ -588,7 +617,7 @@ struct DetailedSMPStatsView: View {
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
}
Section {
Section("Connections") {
infoRow("Created", numOrDash(stats._connCreated))
infoRow("Secured", numOrDash(stats._connCreated))
infoRow("Completed", numOrDash(stats._connCompleted))
@@ -597,8 +626,12 @@ struct DetailedSMPStatsView: View {
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
}
Section {
infoRowTwoValues("Enabled", "attempts", stats._ntfKey, stats._ntfKeyAttempts)
infoRowTwoValues("Disabled", "attempts", stats._ntfKeyDeleted, stats._ntfKeyDeleteAttempts)
} header: {
Text("Connections")
Text("Connection notifications")
} footer: {
Text("Starting from \(localTimestamp(statsStartedAt)).")
}
@@ -711,7 +744,5 @@ struct DetailedXFTPStatsView: View {
}
#Preview {
ServersSummaryView(
serversSummary: Binding.constant(nil)
)
ServersSummaryView()
}
@@ -64,7 +64,7 @@ struct DatabaseErrorView: View {
case let .migrationError(mtrError):
titleText("Incompatible database version")
fileNameText(dbFile)
Text("Error: ") + Text(DatabaseErrorView.mtrErrorDescription(mtrError))
Text("Error: ") + Text(mtrErrorDescription(mtrError))
}
case let .errorSQL(dbFile, migrationSQLError):
titleText("Database error")
@@ -105,15 +105,6 @@ struct DatabaseErrorView: View {
Text("Migrations: \(ms.joined(separator: ", "))")
}
static func mtrErrorDescription(_ err: MTRError) -> LocalizedStringKey {
switch err {
case let .noDown(dbMigrations):
return "database version is newer than the app, but no down migration for: \(dbMigrations.joined(separator: ", "))"
case let .different(appMigration, dbMigration):
return "different migration in the app/database: \(appMigration) / \(dbMigration)"
}
}
private func databaseKeyField(onSubmit: @escaping () -> Void) -> some View {
PassphraseField(key: $dbKey, placeholder: "Enter passphrase…", valid: validKey(dbKey), onSubmit: onSubmit)
}
@@ -16,18 +16,10 @@ struct ChatInfoImage: View {
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var body: some View {
var iconName: String
switch chat.chatInfo {
case .direct: iconName = "person.crop.circle.fill"
case .group: iconName = "person.2.circle.fill"
case .local: iconName = "folder.circle.fill"
case .contactRequest: iconName = "person.crop.circle.fill"
default: iconName = "circle.fill"
}
let iconColor = if case .local = chat.chatInfo { theme.appColors.primaryVariant2 } else { color }
return ProfileImage(
imageStr: chat.chatInfo.image,
iconName: iconName,
iconName: chatIconName(chat.chatInfo),
size: size,
color: iconColor
)
@@ -23,8 +23,10 @@ struct ChatViewBackground: ViewModifier {
var image = context.resolve(image)
let rect = CGRectMake(0, 0, size.width, size.height)
func repeatDraw(_ imageScale: CGFloat) {
// Prevent range bounds crash and dividing by zero
if size.height == 0 || size.width == 0 || image.size.height == 0 || image.size.width == 0 { return }
image.shading = .color(tint)
let scale = imageScale * 1.57 // for some reason a wallpaper on iOS looks smaller than on Android
let scale = imageScale * 2.5 // scale wallpaper for iOS
for h in 0 ... Int(size.height / image.size.height / scale) {
for w in 0 ... Int(size.width / image.size.width / scale) {
let rect = CGRectMake(CGFloat(w) * image.size.width * scale, CGFloat(h) * image.size.height * scale, image.size.width * scale, image.size.height * scale)
@@ -9,8 +9,6 @@
import SwiftUI
import SimpleXChat
let defaultProfileImageCorner = 22.5
struct ProfileImage: View {
@EnvironmentObject var theme: AppTheme
var imageStr: String? = nil
@@ -34,26 +32,6 @@ struct ProfileImage: View {
}
}
private let squareToCircleRatio = 0.935
private let radiusFactor = (1 - squareToCircleRatio) / 50
@ViewBuilder func clipProfileImage(_ img: Image, size: CGFloat, radius: Double) -> some View {
let v = img.resizable()
if radius >= 50 {
v.frame(width: size, height: size).clipShape(Circle())
} else if radius <= 0 {
let sz = size * squareToCircleRatio
v.frame(width: sz, height: sz).padding((size - sz) / 2)
} else {
let sz = size * (squareToCircleRatio + radius * radiusFactor)
v.frame(width: sz, height: sz)
.clipShape(RoundedRectangle(cornerRadius: sz * radius / 100, style: .continuous))
.padding((size - sz) / 2)
}
}
extension Color {
func asAnotherColorFromSecondary(_ theme: AppTheme) -> Color {
return self
@@ -1,26 +0,0 @@
//
// VideoUtils.swift
// SimpleX (iOS)
//
// Created by Avently on 25.12.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import AVFoundation
import Foundation
import SimpleXChat
func makeVideoQualityLower(_ input: URL, outputUrl: URL) async -> Bool {
let asset: AVURLAsset = AVURLAsset(url: input, options: nil)
if let s = AVAssetExportSession(asset: asset, presetName: AVAssetExportPreset640x480) {
s.outputURL = outputUrl
s.outputFileType = .mp4
s.metadataItemFilter = AVMetadataItemFilter.forSharing()
await s.export()
if let err = s.error {
logger.error("Failed to export video with error: \(err)")
}
return s.status == .completed
}
return false
}
@@ -17,3 +17,37 @@ extension View {
}
}
}
extension Notification.Name {
static let chatViewWillBeginScrolling = Notification.Name("chatWillBeginScrolling")
}
struct PrivacyBlur: ViewModifier {
var enabled: Bool = true
@Binding var blurred: Bool
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var blurRadius: Int = 0
func body(content: Content) -> some View {
if blurRadius > 0 {
// parallel ifs are necessary here because otherwise some views flicker,
// e.g. when playing video
content
.blur(radius: blurred && enabled ? CGFloat(blurRadius) * 0.5 : 0)
.overlay {
if (blurred && enabled) {
Color.clear.contentShape(Rectangle())
.onTapGesture {
blurred = false
}
}
}
.onReceive(NotificationCenter.default.publisher(for: .chatViewWillBeginScrolling)) { _ in
if !blurred {
blurred = true
}
}
} else {
content
}
}
}
@@ -64,7 +64,7 @@ struct LocalAuthView: View {
deleteAppDatabaseAndFiles()
// Clear sensitive data on screen just in case app fails to hide its views while new database is created
m.chatId = nil
m.reversedChatItems = []
ItemsModel.shared.reversedChatItems = []
m.chats = []
m.users = []
_ = kcAppPassword.set(password)
@@ -662,7 +662,7 @@ private struct PassphraseConfirmationView: View {
if case .chatCmdError(_, .errorDatabase(.errorOpen(.errorNotADatabase))) = error as? ChatResponse {
showErrorOnMigrationIfNeeded(.errorNotADatabase(dbFile: ""), $alert)
} else {
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(String(describing: error)))
alert = .error(title: "Error", error: NSLocalizedString("Error verifying passphrase:", comment: "") + " " + String(responseError(error)))
}
}
}
@@ -331,7 +331,7 @@ struct MigrateToDevice: View {
case let .migrationError(mtrError):
("Incompatible database version",
nil,
"\(NSLocalizedString("Error: ", comment: "")) \(DatabaseErrorView.mtrErrorDescription(mtrError))",
"\(NSLocalizedString("Error: ", comment: "")) \(mtrErrorDescription(mtrError))",
nil)
}
default: ("Error", nil, "Unknown error", nil)
@@ -37,7 +37,7 @@ struct ConnectDesktopView: View {
case badInvitationError
case badVersionError(version: String?)
case desktopDisconnectedError
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -160,7 +160,7 @@ struct ConnectDesktopView: View {
case .desktopDisconnectedError:
Alert(title: Text("Connection terminated"))
case let .error(title, error):
Alert(title: Text(title), message: Text(error))
mkAlert(title: title, message: error)
}
}
.interactiveDismissDisabled(m.activeRemoteCtrl)
@@ -32,6 +32,7 @@ extension AppSettings {
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 = privacyProtectScreen { def.setValue(val, forKey: DEFAULT_PRIVACY_PROTECT_SCREEN) }
if let val = privacyMediaBlurRadius { def.setValue(val, forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) }
if let val = notificationMode { ChatModel.shared.notificationMode = val.toNotificationsMode() }
if let val = notificationPreviewMode { ntfPreviewModeGroupDefault.set(val) }
if let val = webrtcPolicyRelay { def.setValue(val, forKey: DEFAULT_WEBRTC_POLICY_RELAY) }
@@ -62,6 +63,7 @@ extension AppSettings {
c.privacyShowChatPreviews = def.bool(forKey: DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS)
c.privacySaveLastDraft = def.bool(forKey: DEFAULT_PRIVACY_SAVE_LAST_DRAFT)
c.privacyProtectScreen = def.bool(forKey: DEFAULT_PRIVACY_PROTECT_SCREEN)
c.privacyMediaBlurRadius = def.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS)
c.notificationMode = AppSettingsNotificationMode.from(ChatModel.shared.notificationMode)
c.notificationPreviewMode = ntfPreviewModeGroupDefault.get()
c.webrtcPolicyRelay = def.bool(forKey: DEFAULT_WEBRTC_POLICY_RELAY)
@@ -74,7 +76,7 @@ extension AppSettings {
c.androidCallOnLockScreen = AppSettingsLockScreenCalls(rawValue: def.string(forKey: ANDROID_DEFAULT_CALL_ON_LOCK_SCREEN)!)
c.iosCallKitEnabled = callKitEnabledGroupDefault.get()
c.iosCallKitCallsInRecents = def.bool(forKey: DEFAULT_CALL_KIT_CALLS_IN_RECENTS)
c.uiProfileImageCornerRadius = def.float(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiProfileImageCornerRadius = def.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS)
c.uiColorScheme = currentThemeDefault.get()
c.uiDarkColorScheme = systemDarkThemeDefault.get()
c.uiCurrentThemeIds = currentThemeIdsDefault.get()
@@ -143,7 +143,8 @@ struct AppearanceSettings: View {
Text("Themes")
.foregroundColor(theme.colors.secondary)
}
.onChange(of: profileImageCornerRadius) { _ in
.onChange(of: profileImageCornerRadius) { cornerRadius in
profileImageCornerRadiusGroupDefault.set(cornerRadius)
saveThemeToDatabase(nil)
}
.onChange(of: colorMode) { mode in
@@ -32,7 +32,6 @@ struct NetworkAndServers: View {
@EnvironmentObject var theme: AppTheme
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
@AppStorage(DEFAULT_SHOW_SENT_VIA_RPOXY) private var showSentViaProxy = false
@AppStorage(DEFAULT_SHOW_SUBSCRIPTION_PERCENTAGE) private var showSubscriptionPercentage = false
@State private var cfgLoaded = false
@State private var currentNetCfg = NetCfg.defaults
@State private var netCfg = NetCfg.defaults
@@ -62,8 +61,6 @@ struct NetworkAndServers: View {
Text("XFTP servers")
}
Toggle("Subscription percentage", isOn: $showSubscriptionPercentage)
Picker("Use .onion hosts", selection: $onionHosts) {
ForEach(OnionHosts.values, id: \.self) { Text($0.text) }
}
@@ -22,6 +22,7 @@ struct PrivacySettings: View {
@AppStorage(DEFAULT_PRIVACY_PROTECT_SCREEN) private var protectScreen = false
@AppStorage(DEFAULT_PERFORM_LA) private var prefPerformLA = false
@State private var currentLAMode = privacyLocalAuthModeDefault.get()
@AppStorage(DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) private var privacyMediaBlurRadius: Int = 0
@State private var contactReceipts = false
@State private var contactReceiptsReset = false
@State private var contactReceiptsOverrides = 0
@@ -69,6 +70,9 @@ struct PrivacySettings: View {
Section {
settingsRow("network", color: theme.colors.secondary) {
Toggle("Send link previews", isOn: $useLinkPreviews)
.onChange(of: useLinkPreviews) { linkPreviews in
privacyLinkPreviewsGroupDefault.set(linkPreviews)
}
}
settingsRow("message", color: theme.colors.secondary) {
Toggle("Show last messages", isOn: $showChatPreviews)
@@ -113,6 +117,22 @@ struct PrivacySettings: View {
privacyAcceptImagesGroupDefault.set($0)
}
}
settingsRow("circle.rectangle.filled.pattern.diagonalline", color: theme.colors.secondary) {
Picker("Blur media", selection: $privacyMediaBlurRadius) {
let values = [0, 12, 24, 48] + ([0, 12, 24, 48].contains(privacyMediaBlurRadius) ? [] : [privacyMediaBlurRadius])
ForEach(values, id: \.self) { radius in
let text: String = switch radius {
case 0: NSLocalizedString("Off", comment: "blur media")
case 12: NSLocalizedString("Soft", comment: "blur media")
case 24: NSLocalizedString("Medium", comment: "blur media")
case 48: NSLocalizedString("Strong", comment: "blur media")
default: "\(radius)"
}
Text(text)
}
}
}
.frame(height: 36)
settingsRow("network.badge.shield.half.filled", color: theme.colors.secondary) {
Toggle("Protect IP address", isOn: $askToApproveRelays)
}
@@ -348,6 +368,7 @@ struct SimplexLockView: View {
@State private var selfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@State private var currentSelfDestruct: Bool = UserDefaults.standard.bool(forKey: DEFAULT_LA_SELF_DESTRUCT)
@AppStorage(DEFAULT_LA_SELF_DESTRUCT_DISPLAY_NAME) private var selfDestructDisplayName = ""
@AppStorage(GROUP_DEFAULT_ALLOW_SHARE_EXTENSION, store: groupDefaults) private var allowShareExtension = false
@State private var performLAToggleReset = false
@State private var performLAModeReset = false
@State private var performLASelfDestructReset = false
@@ -419,6 +440,12 @@ struct SimplexLockView: View {
}
}
if performLA {
Section("Share to SimpleX") {
Toggle("Allow sharing", isOn: $allowShareExtension)
}
}
if performLA && laMode == .passcode {
Section(header: Text("Self-destruct passcode").foregroundColor(theme.colors.secondary)) {
Toggle(isOn: $selfDestruct) {
@@ -443,6 +470,7 @@ struct SimplexLockView: View {
}
}
.onChange(of: performLA) { performLAToggle in
performLAGroupDefault.set(performLAToggle)
prefLANoticeShown = true
if performLAToggleReset {
performLAToggleReset = false
@@ -175,10 +175,6 @@ func testServerConnection(server: Binding<ServerCfg>) async -> ProtocolTestFailu
}
}
func serverHostname(_ srv: String) -> String {
parseServerAddress(srv)?.hostnames.first ?? srv
}
struct ProtocolServerView_Previews: PreviewProvider {
static var previews: some View {
ProtocolServerView(
@@ -18,7 +18,7 @@ let appBuild = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as?
let DEFAULT_SHOW_LA_NOTICE = "showLocalAuthenticationNotice"
let DEFAULT_LA_NOTICE_SHOWN = "localAuthenticationNoticeShown"
let DEFAULT_PERFORM_LA = "performLocalAuthentication"
let DEFAULT_PERFORM_LA = "performLocalAuthentication" // deprecated, moved to app group
let DEFAULT_LA_MODE = "localAuthenticationMode"
let DEFAULT_LA_LOCK_DELAY = "localAuthenticationLockDelay"
let DEFAULT_LA_SELF_DESTRUCT = "localAuthenticationSelfDestruct"
@@ -28,12 +28,13 @@ let DEFAULT_WEBRTC_POLICY_RELAY = "webrtcPolicyRelay"
let DEFAULT_WEBRTC_ICE_SERVERS = "webrtcICEServers"
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_LINK_PREVIEWS = "privacyLinkPreviews"
let DEFAULT_PRIVACY_LINK_PREVIEWS = "privacyLinkPreviews" // deprecated, moved to app group
let DEFAULT_PRIVACY_SIMPLEX_LINK_MODE = "privacySimplexLinkMode"
let DEFAULT_PRIVACY_SHOW_CHAT_PREVIEWS = "privacyShowChatPreviews"
let DEFAULT_PRIVACY_SAVE_LAST_DRAFT = "privacySaveLastDraft"
let DEFAULT_PRIVACY_PROTECT_SCREEN = "privacyProtectScreen"
let DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET = "privacyDeliveryReceiptsSet"
let DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS = "privacyMediaBlurRadius"
let DEFAULT_EXPERIMENTAL_CALLS = "experimentalCalls"
let DEFAULT_CHAT_ARCHIVE_NAME = "chatArchiveName"
let DEFAULT_CHAT_ARCHIVE_TIME = "chatArchiveTime"
@@ -87,6 +88,7 @@ let appDefaults: [String: Any] = [
DEFAULT_PRIVACY_SAVE_LAST_DRAFT: true,
DEFAULT_PRIVACY_PROTECT_SCREEN: false,
DEFAULT_PRIVACY_DELIVERY_RECEIPTS_SET: false,
DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS: 0,
DEFAULT_EXPERIMENTAL_CALLS: false,
DEFAULT_CHAT_V3_DB_MIGRATION: V3DBMigrationState.offer.rawValue,
DEFAULT_DEVELOPER_TOOLS: false,
@@ -163,6 +165,9 @@ let themeOverridesDefault: CodableDefault<[ThemeOverrides]> = CodableDefault(def
func setGroupDefaults() {
privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES))
performLAGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PERFORM_LA))
privacyLinkPreviewsGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_LINK_PREVIEWS))
profileImageCornerRadiusGroupDefault.set(UserDefaults.standard.double(forKey: DEFAULT_PROFILE_IMAGE_CORNER_RADIUS))
}
public class StringDefault {
@@ -30,7 +30,7 @@ struct UserAddressView: View {
case deleteAddress
case profileAddress(on: Bool)
case shareOnCreate
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -185,7 +185,7 @@ struct UserAddressView: View {
}, secondaryButton: .cancel()
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
return mkAlert(title: title, message: error)
}
}
}
@@ -30,7 +30,7 @@ struct UserProfilesView: View {
case hiddenProfilesNotice
case muteProfileAlert
case activateUserError(error: String)
case error(title: LocalizedStringKey, error: LocalizedStringKey = "")
case error(title: LocalizedStringKey, error: LocalizedStringKey?)
var id: String {
switch self {
@@ -172,7 +172,7 @@ struct UserProfilesView: View {
message: Text(err)
)
case let .error(title, error):
return Alert(title: Text(title), message: Text(error))
return mkAlert(title: title, message: error)
}
}
}
@@ -709,8 +709,8 @@
<target>Всички нови съобщения от %@ ще бъдат скрити!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All users" xml:space="preserve">
<source>All users</source>
<trans-unit id="All profiles" xml:space="preserve">
<source>All profiles</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected." xml:space="preserve">
@@ -1058,6 +1058,10 @@
<target>Блокиран от админ</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Blur media" xml:space="preserve">
<source>Blur media</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can add message reactions." xml:space="preserve">
<source>Both you and your contact can add message reactions.</source>
<target>И вие, и вашият контакт можете да добавяте реакции към съобщението.</target>
@@ -1509,6 +1513,10 @@ This is your own one-time link!</source>
<target>Грешка при свързване (AUTH)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection notifications" xml:space="preserve">
<source>Connection notifications</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection request sent!" xml:space="preserve">
<source>Connection request sent!</source>
<target>Заявката за връзка е изпратена!</target>
@@ -1705,8 +1713,8 @@ This is your own one-time link!</source>
<target>Текуща парола…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current user" xml:space="preserve">
<source>Current user</source>
<trans-unit id="Current profile" xml:space="preserve">
<source>Current profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
@@ -2089,10 +2097,18 @@ This cannot be undone!</source>
<target>Настолни устройства</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server address of %@ is incompatible with forwarding server %@ settings." xml:space="preserve">
<source>Destination server address of %@ is incompatible with forwarding server %@ settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server error: %@" xml:space="preserve">
<source>Destination server error: %@</source>
<note>snd error text</note>
</trans-unit>
<trans-unit id="Destination server version of %@ is incompatible with forwarding server %@." xml:space="preserve">
<source>Destination server version of %@ is incompatible with forwarding server %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Detailed statistics" xml:space="preserve">
<source>Detailed statistics</source>
<note>No comment provided by engineer.</note>
@@ -2156,6 +2172,10 @@ This cannot be undone!</source>
<target>Деактивиране за всички</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disabled" xml:space="preserve">
<source>Disabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disappearing message" xml:space="preserve">
<source>Disappearing message</source>
<target>Изчезващо съобщение</target>
@@ -2376,6 +2396,10 @@ This cannot be undone!</source>
<target>Активирай kод за достъп за самоунищожение</target>
<note>set passcode view</note>
</trans-unit>
<trans-unit id="Enabled" xml:space="preserve">
<source>Enabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enabled for" xml:space="preserve">
<source>Enabled for</source>
<target>Активирано за</target>
@@ -2546,6 +2570,10 @@ This cannot be undone!</source>
<target>Грешка при промяна на настройката</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating address" xml:space="preserve">
<source>Error creating address</source>
<target>Грешка при създаване на адрес</target>
@@ -3040,6 +3068,18 @@ This cannot be undone!</source>
<target>Препратено от</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server %@ failed to connect to destination server %@. Please try later." xml:space="preserve">
<source>Forwarding server %@ failed to connect to destination server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server address is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server address is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server version is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server version is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server: %@&#10;Destination server error: %@" xml:space="preserve">
<source>Forwarding server: %1$@
Destination server error: %2$@</source>
@@ -3851,6 +3891,10 @@ This is your link for group %@!</source>
<target>Макс. 30 секунди, получено незабавно.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Medium" xml:space="preserve">
<source>Medium</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Member" xml:space="preserve">
<source>Member</source>
<target>Член</target>
@@ -4287,7 +4331,7 @@ This is your link for group %@!</source>
<trans-unit id="Off" xml:space="preserve">
<source>Off</source>
<target>Изключено</target>
<note>No comment provided by engineer.</note>
<note>blur media</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
@@ -4638,10 +4682,6 @@ Error: %@</source>
<target>Моля, съхранявайте паролата на сигурно място, НЯМА да можете да я промените, ако я загубите.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please try later." xml:space="preserve">
<source>Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Polish interface" xml:space="preserve">
<source>Polish interface</source>
<target>Полски интерфейс</target>
@@ -5779,6 +5819,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Show message status</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show percentage" xml:space="preserve">
<source>Show percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show preview" xml:space="preserve">
<source>Show preview</source>
<target>Показване на визуализация</target>
@@ -5891,6 +5935,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Малки групи (максимум 20)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Soft" xml:space="preserve">
<source>Soft</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Some non-fatal errors occurred during import - you may see Chat console for more details." xml:space="preserve">
<source>Some non-fatal errors occurred during import - you may see Chat console for more details.</source>
<target>Някои не-фатални грешки са възникнали по време на импортиране - може да видите конзолата за повече подробности.</target>
@@ -5989,6 +6037,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Спиране на чата</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Strong" xml:space="preserve">
<source>Strong</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Submit" xml:space="preserve">
<source>Submit</source>
<target>Изпрати</target>
@@ -6002,10 +6054,6 @@ Enable in *Network &amp; servers* settings.</source>
<source>Subscription errors</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscription percentage" xml:space="preserve">
<source>Subscription percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscriptions ignored" xml:space="preserve">
<source>Subscriptions ignored</source>
<note>No comment provided by engineer.</note>
@@ -7060,8 +7108,8 @@ Repeat join request?</source>
<target>Можете да го направите видим за вашите контакти в SimpleX чрез Настройки.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
<source>You can now send messages to %@</source>
<trans-unit id="You can now chat with %@" xml:space="preserve">
<source>You can now chat with %@</source>
<target>Вече можете да изпращате съобщения до %@</target>
<note>notification body</note>
</trans-unit>
@@ -7468,7 +7516,7 @@ SimpleX сървърите не могат да видят вашия профи
<trans-unit id="blocked by admin" xml:space="preserve">
<source>blocked by admin</source>
<target>блокиран от админ</target>
<note>blocked chat item</note>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="bold" xml:space="preserve">
<source>bold</source>
@@ -8350,4 +8398,154 @@ last received msg: %2$@</source>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/InfoPlist.strings" source-language="en" target-language="bg" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle display name</note>
</trans-unit>
<trans-unit id="CFBundleName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle name</note>
</trans-unit>
<trans-unit id="NSHumanReadableCopyright" xml:space="preserve">
<source>Copyright © 2024 SimpleX Chat. All rights reserved.</source>
<note>Copyright (human-readable)</note>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/Localizable.strings" source-language="en" target-language="bg" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="%@" xml:space="preserve">
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
<source>Currently maximum supported file size is %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database downgrade required" xml:space="preserve">
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is different from saved in the keychain." xml:space="preserve">
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Dismiss Sheet" xml:space="preserve">
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing message" xml:space="preserve">
<source>Error preparing message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error: %@" xml:space="preserve">
<source>Error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="File error" xml:space="preserve">
<source>File error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incompatible database version" xml:space="preserve">
<source>Incompatible database version</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keep Trying" xml:space="preserve">
<source>Keep Trying</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keychain error" xml:space="preserve">
<source>Keychain error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Large file!" xml:space="preserve">
<source>Large file!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No active profile" xml:space="preserve">
<source>No active profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No network connection" xml:space="preserve">
<source>No network connection</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to downgrade the database." xml:space="preserve">
<source>Open the app to downgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to upgrade the database." xml:space="preserve">
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unsupported format" xml:space="preserve">
<source>Unsupported format</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong database passphrase" xml:space="preserve">
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -687,8 +687,8 @@
<source>All new messages from %@ will be hidden!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All users" xml:space="preserve">
<source>All users</source>
<trans-unit id="All profiles" xml:space="preserve">
<source>All profiles</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected." xml:space="preserve">
@@ -1019,6 +1019,10 @@
<source>Blocked by admin</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Blur media" xml:space="preserve">
<source>Blur media</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can add message reactions." xml:space="preserve">
<source>Both you and your contact can add message reactions.</source>
<target>Vy i váš kontakt můžete přidávat reakce na zprávy.</target>
@@ -1448,6 +1452,10 @@ This is your own one-time link!</source>
<target>Chyba spojení (AUTH)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection notifications" xml:space="preserve">
<source>Connection notifications</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection request sent!" xml:space="preserve">
<source>Connection request sent!</source>
<target>Požadavek na připojení byl odeslán!</target>
@@ -1635,8 +1643,8 @@ This is your own one-time link!</source>
<target>Aktuální přístupová fráze…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current user" xml:space="preserve">
<source>Current user</source>
<trans-unit id="Current profile" xml:space="preserve">
<source>Current profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
@@ -2011,10 +2019,18 @@ This cannot be undone!</source>
<source>Desktop devices</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server address of %@ is incompatible with forwarding server %@ settings." xml:space="preserve">
<source>Destination server address of %@ is incompatible with forwarding server %@ settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server error: %@" xml:space="preserve">
<source>Destination server error: %@</source>
<note>snd error text</note>
</trans-unit>
<trans-unit id="Destination server version of %@ is incompatible with forwarding server %@." xml:space="preserve">
<source>Destination server version of %@ is incompatible with forwarding server %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Detailed statistics" xml:space="preserve">
<source>Detailed statistics</source>
<note>No comment provided by engineer.</note>
@@ -2078,6 +2094,10 @@ This cannot be undone!</source>
<target>Vypnout pro všechny</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disabled" xml:space="preserve">
<source>Disabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disappearing message" xml:space="preserve">
<source>Disappearing message</source>
<target>Mizící zpráva</target>
@@ -2289,6 +2309,10 @@ This cannot be undone!</source>
<target>Povolit sebedestrukční heslo</target>
<note>set passcode view</note>
</trans-unit>
<trans-unit id="Enabled" xml:space="preserve">
<source>Enabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enabled for" xml:space="preserve">
<source>Enabled for</source>
<note>No comment provided by engineer.</note>
@@ -2451,6 +2475,10 @@ This cannot be undone!</source>
<target>Chyba změny nastavení</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating address" xml:space="preserve">
<source>Error creating address</source>
<target>Chyba při vytváření adresy</target>
@@ -2928,6 +2956,18 @@ This cannot be undone!</source>
<source>Forwarded from</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server %@ failed to connect to destination server %@. Please try later." xml:space="preserve">
<source>Forwarding server %@ failed to connect to destination server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server address is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server address is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server version is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server version is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server: %@&#10;Destination server error: %@" xml:space="preserve">
<source>Forwarding server: %1$@
Destination server error: %2$@</source>
@@ -3708,6 +3748,10 @@ This is your link for group %@!</source>
<target>Max 30 vteřin, přijato okamžitě.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Medium" xml:space="preserve">
<source>Medium</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Member" xml:space="preserve">
<source>Member</source>
<target>Člen</target>
@@ -4125,7 +4169,7 @@ This is your link for group %@!</source>
<trans-unit id="Off" xml:space="preserve">
<source>Off</source>
<target>Vypnout</target>
<note>No comment provided by engineer.</note>
<note>blur media</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
@@ -4460,10 +4504,6 @@ Error: %@</source>
<target>Heslo uložte bezpečně, v případě jeho ztráty jej NEBUDE možné změnit.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please try later." xml:space="preserve">
<source>Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Polish interface" xml:space="preserve">
<source>Polish interface</source>
<target>Polské rozhraní</target>
@@ -5572,6 +5612,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Show message status</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show percentage" xml:space="preserve">
<source>Show percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show preview" xml:space="preserve">
<source>Show preview</source>
<target>Zobrazení náhledu</target>
@@ -5682,6 +5726,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Malé skupiny (max. 20)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Soft" xml:space="preserve">
<source>Soft</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Some non-fatal errors occurred during import - you may see Chat console for more details." xml:space="preserve">
<source>Some non-fatal errors occurred during import - you may see Chat console for more details.</source>
<target>Během importu došlo k nezávažným chybám - podrobnosti naleznete v chat konzoli.</target>
@@ -5776,6 +5824,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Stopping chat</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Strong" xml:space="preserve">
<source>Strong</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Submit" xml:space="preserve">
<source>Submit</source>
<target>Odeslat</target>
@@ -5789,10 +5841,6 @@ Enable in *Network &amp; servers* settings.</source>
<source>Subscription errors</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscription percentage" xml:space="preserve">
<source>Subscription percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscriptions ignored" xml:space="preserve">
<source>Subscriptions ignored</source>
<note>No comment provided by engineer.</note>
@@ -6793,8 +6841,8 @@ Repeat join request?</source>
<source>You can make it visible to your SimpleX contacts via Settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
<source>You can now send messages to %@</source>
<trans-unit id="You can now chat with %@" xml:space="preserve">
<source>You can now chat with %@</source>
<target>Nyní můžete posílat zprávy %@</target>
<note>notification body</note>
</trans-unit>
@@ -7187,7 +7235,7 @@ Servery SimpleX nevidí váš profil.</target>
</trans-unit>
<trans-unit id="blocked by admin" xml:space="preserve">
<source>blocked by admin</source>
<note>blocked chat item</note>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="bold" xml:space="preserve">
<source>bold</source>
@@ -8047,4 +8095,154 @@ last received msg: %2$@</source>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/InfoPlist.strings" source-language="en" target-language="cs" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle display name</note>
</trans-unit>
<trans-unit id="CFBundleName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle name</note>
</trans-unit>
<trans-unit id="NSHumanReadableCopyright" xml:space="preserve">
<source>Copyright © 2024 SimpleX Chat. All rights reserved.</source>
<note>Copyright (human-readable)</note>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/Localizable.strings" source-language="en" target-language="cs" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="%@" xml:space="preserve">
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
<source>Currently maximum supported file size is %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database downgrade required" xml:space="preserve">
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is different from saved in the keychain." xml:space="preserve">
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Dismiss Sheet" xml:space="preserve">
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing message" xml:space="preserve">
<source>Error preparing message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error: %@" xml:space="preserve">
<source>Error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="File error" xml:space="preserve">
<source>File error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incompatible database version" xml:space="preserve">
<source>Incompatible database version</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keep Trying" xml:space="preserve">
<source>Keep Trying</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keychain error" xml:space="preserve">
<source>Keychain error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Large file!" xml:space="preserve">
<source>Large file!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No active profile" xml:space="preserve">
<source>No active profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No network connection" xml:space="preserve">
<source>No network connection</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to downgrade the database." xml:space="preserve">
<source>Open the app to downgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to upgrade the database." xml:space="preserve">
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unsupported format" xml:space="preserve">
<source>Unsupported format</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong database passphrase" xml:space="preserve">
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -718,9 +718,9 @@
<target>All new messages from %@ will be hidden!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All users" xml:space="preserve">
<source>All users</source>
<target>All users</target>
<trans-unit id="All profiles" xml:space="preserve">
<source>All profiles</source>
<target>All profiles</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected." xml:space="preserve">
@@ -1073,6 +1073,11 @@
<target>Blocked by admin</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Blur media" xml:space="preserve">
<source>Blur media</source>
<target>Blur media</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can add message reactions." xml:space="preserve">
<source>Both you and your contact can add message reactions.</source>
<target>Both you and your contact can add message reactions.</target>
@@ -1538,6 +1543,11 @@ This is your own one-time link!</target>
<target>Connection error (AUTH)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection notifications" xml:space="preserve">
<source>Connection notifications</source>
<target>Connection notifications</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection request sent!" xml:space="preserve">
<source>Connection request sent!</source>
<target>Connection request sent!</target>
@@ -1738,9 +1748,9 @@ This is your own one-time link!</target>
<target>Current passphrase…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current user" xml:space="preserve">
<source>Current user</source>
<target>Current user</target>
<trans-unit id="Current profile" xml:space="preserve">
<source>Current profile</source>
<target>Current profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
@@ -2128,11 +2138,21 @@ This cannot be undone!</target>
<target>Desktop devices</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server address of %@ is incompatible with forwarding server %@ settings." xml:space="preserve">
<source>Destination server address of %@ is incompatible with forwarding server %@ settings.</source>
<target>Destination server address of %@ is incompatible with forwarding server %@ settings.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server error: %@" xml:space="preserve">
<source>Destination server error: %@</source>
<target>Destination server error: %@</target>
<note>snd error text</note>
</trans-unit>
<trans-unit id="Destination server version of %@ is incompatible with forwarding server %@." xml:space="preserve">
<source>Destination server version of %@ is incompatible with forwarding server %@.</source>
<target>Destination server version of %@ is incompatible with forwarding server %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Detailed statistics" xml:space="preserve">
<source>Detailed statistics</source>
<target>Detailed statistics</target>
@@ -2198,6 +2218,11 @@ This cannot be undone!</target>
<target>Disable for all</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disabled" xml:space="preserve">
<source>Disabled</source>
<target>Disabled</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disappearing message" xml:space="preserve">
<source>Disappearing message</source>
<target>Disappearing message</target>
@@ -2423,6 +2448,11 @@ This cannot be undone!</target>
<target>Enable self-destruct passcode</target>
<note>set passcode view</note>
</trans-unit>
<trans-unit id="Enabled" xml:space="preserve">
<source>Enabled</source>
<target>Enabled</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enabled for" xml:space="preserve">
<source>Enabled for</source>
<target>Enabled for</target>
@@ -2593,6 +2623,11 @@ This cannot be undone!</target>
<target>Error changing setting</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source>
<target>Error connecting to forwarding server %@. Please try later.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating address" xml:space="preserve">
<source>Error creating address</source>
<target>Error creating address</target>
@@ -3099,6 +3134,21 @@ This cannot be undone!</target>
<target>Forwarded from</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server %@ failed to connect to destination server %@. Please try later." xml:space="preserve">
<source>Forwarding server %@ failed to connect to destination server %@. Please try later.</source>
<target>Forwarding server %@ failed to connect to destination server %@. Please try later.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server address is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server address is incompatible with network settings: %@.</source>
<target>Forwarding server address is incompatible with network settings: %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server version is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server version is incompatible with network settings: %@.</source>
<target>Forwarding server version is incompatible with network settings: %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server: %@&#10;Destination server error: %@" xml:space="preserve">
<source>Forwarding server: %1$@
Destination server error: %2$@</source>
@@ -3918,6 +3968,11 @@ This is your link for group %@!</target>
<target>Max 30 seconds, received instantly.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Medium" xml:space="preserve">
<source>Medium</source>
<target>Medium</target>
<note>blur media</note>
</trans-unit>
<trans-unit id="Member" xml:space="preserve">
<source>Member</source>
<target>Member</target>
@@ -4370,7 +4425,7 @@ This is your link for group %@!</target>
<trans-unit id="Off" xml:space="preserve">
<source>Off</source>
<target>Off</target>
<note>No comment provided by engineer.</note>
<note>blur media</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
@@ -4726,11 +4781,6 @@ Error: %@</target>
<target>Please store passphrase securely, you will NOT be able to change it if you lose it.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please try later." xml:space="preserve">
<source>Please try later.</source>
<target>Please try later.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Polish interface" xml:space="preserve">
<source>Polish interface</source>
<target>Polish interface</target>
@@ -5918,6 +5968,11 @@ Enable in *Network &amp; servers* settings.</target>
<target>Show message status</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show percentage" xml:space="preserve">
<source>Show percentage</source>
<target>Show percentage</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show preview" xml:space="preserve">
<source>Show preview</source>
<target>Show preview</target>
@@ -6033,6 +6088,11 @@ Enable in *Network &amp; servers* settings.</target>
<target>Small groups (max 20)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Soft" xml:space="preserve">
<source>Soft</source>
<target>Soft</target>
<note>blur media</note>
</trans-unit>
<trans-unit id="Some non-fatal errors occurred during import - you may see Chat console for more details." xml:space="preserve">
<source>Some non-fatal errors occurred during import - you may see Chat console for more details.</source>
<target>Some non-fatal errors occurred during import - you may see Chat console for more details.</target>
@@ -6133,6 +6193,11 @@ Enable in *Network &amp; servers* settings.</target>
<target>Stopping chat</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Strong" xml:space="preserve">
<source>Strong</source>
<target>Strong</target>
<note>blur media</note>
</trans-unit>
<trans-unit id="Submit" xml:space="preserve">
<source>Submit</source>
<target>Submit</target>
@@ -6148,11 +6213,6 @@ Enable in *Network &amp; servers* settings.</target>
<target>Subscription errors</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscription percentage" xml:space="preserve">
<source>Subscription percentage</source>
<target>Subscription percentage</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscriptions ignored" xml:space="preserve">
<source>Subscriptions ignored</source>
<target>Subscriptions ignored</target>
@@ -7231,9 +7291,9 @@ Repeat join request?</target>
<target>You can make it visible to your SimpleX contacts via Settings.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
<source>You can now send messages to %@</source>
<target>You can now send messages to %@</target>
<trans-unit id="You can now chat with %@" xml:space="preserve">
<source>You can now chat with %@</source>
<target>You can now chat with %@</target>
<note>notification body</note>
</trans-unit>
<trans-unit id="You can set lock screen notification preview via settings." xml:space="preserve">
@@ -7640,7 +7700,7 @@ SimpleX servers cannot see your profile.</target>
<trans-unit id="blocked by admin" xml:space="preserve">
<source>blocked by admin</source>
<target>blocked by admin</target>
<note>blocked chat item</note>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="bold" xml:space="preserve">
<source>bold</source>
@@ -8534,4 +8594,188 @@ last received msg: %2$@</target>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/InfoPlist.strings" source-language="en" target-language="en" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
<source>SimpleX SE</source>
<target>SimpleX SE</target>
<note>Bundle display name</note>
</trans-unit>
<trans-unit id="CFBundleName" xml:space="preserve">
<source>SimpleX SE</source>
<target>SimpleX SE</target>
<note>Bundle name</note>
</trans-unit>
<trans-unit id="NSHumanReadableCopyright" xml:space="preserve">
<source>Copyright © 2024 SimpleX Chat. All rights reserved.</source>
<target>Copyright © 2024 SimpleX Chat. All rights reserved.</target>
<note>Copyright (human-readable)</note>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/Localizable.strings" source-language="en" target-language="en" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="%@" xml:space="preserve">
<source>%@</source>
<target>%@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<target>Cannot access keychain to save database password</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<target>Comment</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
<source>Currently maximum supported file size is %@.</source>
<target>Currently maximum supported file size is %@.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database downgrade required" xml:space="preserve">
<source>Database downgrade required</source>
<target>Database downgrade required</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<target>Database error</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is different from saved in the keychain." xml:space="preserve">
<source>Database passphrase is different from saved in the keychain.</source>
<target>Database passphrase is different from saved in the keychain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<target>Database upgrade required</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Dismiss Sheet" xml:space="preserve">
<source>Dismiss Sheet</source>
<target>Dismiss Sheet</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<target>Encrypted database</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<target>Error preparing file</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing message" xml:space="preserve">
<source>Error preparing message</source>
<target>Error preparing message</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error: %@" xml:space="preserve">
<source>Error: %@</source>
<target>Error: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="File error" xml:space="preserve">
<source>File error</source>
<target>File error</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incompatible database version" xml:space="preserve">
<source>Incompatible database version</source>
<target>Incompatible database version</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
<target>Invalid migration confirmation</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keep Trying" xml:space="preserve">
<source>Keep Trying</source>
<target>Keep Trying</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keychain error" xml:space="preserve">
<source>Keychain error</source>
<target>Keychain error</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Large file!" xml:space="preserve">
<source>Large file!</source>
<target>Large file!</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No active profile" xml:space="preserve">
<source>No active profile</source>
<target>No active profile</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No network connection" xml:space="preserve">
<source>No network connection</source>
<target>No network connection</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
<target>Ok</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to downgrade the database." xml:space="preserve">
<source>Open the app to downgrade the database.</source>
<target>Open the app to downgrade the database.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to upgrade the database." xml:space="preserve">
<source>Open the app to upgrade the database.</source>
<target>Open the app to upgrade the database.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<target>Please create a profile in the SimpleX app</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<target>Sending File</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<target>Share</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<target>Sharing is not supported when passphrase is not stored in KeyChain.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<target>Unknown database error: %@</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unsupported format" xml:space="preserve">
<source>Unsupported format</source>
<target>Unsupported format</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong database passphrase" xml:space="preserve">
<source>Wrong database passphrase</source>
<target>Wrong database passphrase</target>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -682,8 +682,8 @@
<source>All new messages from %@ will be hidden!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All users" xml:space="preserve">
<source>All users</source>
<trans-unit id="All profiles" xml:space="preserve">
<source>All profiles</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected." xml:space="preserve">
@@ -1013,6 +1013,10 @@
<source>Blocked by admin</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Blur media" xml:space="preserve">
<source>Blur media</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can add message reactions." xml:space="preserve">
<source>Both you and your contact can add message reactions.</source>
<target>Sekä sinä että kontaktisi voivat käyttää viestireaktioita.</target>
@@ -1441,6 +1445,10 @@ This is your own one-time link!</source>
<target>Yhteysvirhe (AUTH)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection notifications" xml:space="preserve">
<source>Connection notifications</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection request sent!" xml:space="preserve">
<source>Connection request sent!</source>
<target>Yhteyspyyntö lähetetty!</target>
@@ -1628,8 +1636,8 @@ This is your own one-time link!</source>
<target>Nykyinen tunnuslause…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current user" xml:space="preserve">
<source>Current user</source>
<trans-unit id="Current profile" xml:space="preserve">
<source>Current profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
@@ -2004,10 +2012,18 @@ This cannot be undone!</source>
<source>Desktop devices</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server address of %@ is incompatible with forwarding server %@ settings." xml:space="preserve">
<source>Destination server address of %@ is incompatible with forwarding server %@ settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server error: %@" xml:space="preserve">
<source>Destination server error: %@</source>
<note>snd error text</note>
</trans-unit>
<trans-unit id="Destination server version of %@ is incompatible with forwarding server %@." xml:space="preserve">
<source>Destination server version of %@ is incompatible with forwarding server %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Detailed statistics" xml:space="preserve">
<source>Detailed statistics</source>
<note>No comment provided by engineer.</note>
@@ -2071,6 +2087,10 @@ This cannot be undone!</source>
<target>Poista käytöstä kaikilta</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disabled" xml:space="preserve">
<source>Disabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disappearing message" xml:space="preserve">
<source>Disappearing message</source>
<target>Tuhoutuva viesti</target>
@@ -2282,6 +2302,10 @@ This cannot be undone!</source>
<target>Ota itsetuhoava pääsykoodi käyttöön</target>
<note>set passcode view</note>
</trans-unit>
<trans-unit id="Enabled" xml:space="preserve">
<source>Enabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enabled for" xml:space="preserve">
<source>Enabled for</source>
<note>No comment provided by engineer.</note>
@@ -2443,6 +2467,10 @@ This cannot be undone!</source>
<target>Virhe asetuksen muuttamisessa</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating address" xml:space="preserve">
<source>Error creating address</source>
<target>Virhe osoitteen luomisessa</target>
@@ -2918,6 +2946,18 @@ This cannot be undone!</source>
<source>Forwarded from</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server %@ failed to connect to destination server %@. Please try later." xml:space="preserve">
<source>Forwarding server %@ failed to connect to destination server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server address is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server address is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server version is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server version is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server: %@&#10;Destination server error: %@" xml:space="preserve">
<source>Forwarding server: %1$@
Destination server error: %2$@</source>
@@ -3698,6 +3738,10 @@ This is your link for group %@!</source>
<target>Enintään 30 sekuntia, vastaanotetaan välittömästi.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Medium" xml:space="preserve">
<source>Medium</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Member" xml:space="preserve">
<source>Member</source>
<target>Jäsen</target>
@@ -4114,7 +4158,7 @@ This is your link for group %@!</source>
<trans-unit id="Off" xml:space="preserve">
<source>Off</source>
<target>Pois</target>
<note>No comment provided by engineer.</note>
<note>blur media</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
@@ -4448,10 +4492,6 @@ Error: %@</source>
<target>Säilytä tunnuslause turvallisesti, ET voi muuttaa sitä, jos kadotat sen.</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please try later." xml:space="preserve">
<source>Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Polish interface" xml:space="preserve">
<source>Polish interface</source>
<target>Puolalainen käyttöliittymä</target>
@@ -5559,6 +5599,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Show message status</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show percentage" xml:space="preserve">
<source>Show percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show preview" xml:space="preserve">
<source>Show preview</source>
<target>Näytä esikatselu</target>
@@ -5668,6 +5712,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>Pienryhmät (max 20)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Soft" xml:space="preserve">
<source>Soft</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Some non-fatal errors occurred during import - you may see Chat console for more details." xml:space="preserve">
<source>Some non-fatal errors occurred during import - you may see Chat console for more details.</source>
<target>Tuonnin aikana tapahtui joitakin ei-vakavia virheitä saatat nähdä Chat-konsolissa lisätietoja.</target>
@@ -5762,6 +5810,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Stopping chat</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Strong" xml:space="preserve">
<source>Strong</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Submit" xml:space="preserve">
<source>Submit</source>
<target>Lähetä</target>
@@ -5775,10 +5827,6 @@ Enable in *Network &amp; servers* settings.</source>
<source>Subscription errors</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscription percentage" xml:space="preserve">
<source>Subscription percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscriptions ignored" xml:space="preserve">
<source>Subscriptions ignored</source>
<note>No comment provided by engineer.</note>
@@ -6778,8 +6826,8 @@ Repeat join request?</source>
<source>You can make it visible to your SimpleX contacts via Settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
<source>You can now send messages to %@</source>
<trans-unit id="You can now chat with %@" xml:space="preserve">
<source>You can now chat with %@</source>
<target>Voit nyt lähettää viestejä %@:lle</target>
<note>notification body</note>
</trans-unit>
@@ -7172,7 +7220,7 @@ SimpleX-palvelimet eivät näe profiiliasi.</target>
</trans-unit>
<trans-unit id="blocked by admin" xml:space="preserve">
<source>blocked by admin</source>
<note>blocked chat item</note>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="bold" xml:space="preserve">
<source>bold</source>
@@ -8031,4 +8079,154 @@ last received msg: %2$@</source>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/InfoPlist.strings" source-language="en" target-language="fi" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle display name</note>
</trans-unit>
<trans-unit id="CFBundleName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle name</note>
</trans-unit>
<trans-unit id="NSHumanReadableCopyright" xml:space="preserve">
<source>Copyright © 2024 SimpleX Chat. All rights reserved.</source>
<note>Copyright (human-readable)</note>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/Localizable.strings" source-language="en" target-language="fi" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="%@" xml:space="preserve">
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
<source>Currently maximum supported file size is %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database downgrade required" xml:space="preserve">
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is different from saved in the keychain." xml:space="preserve">
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Dismiss Sheet" xml:space="preserve">
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing message" xml:space="preserve">
<source>Error preparing message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error: %@" xml:space="preserve">
<source>Error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="File error" xml:space="preserve">
<source>File error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incompatible database version" xml:space="preserve">
<source>Incompatible database version</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keep Trying" xml:space="preserve">
<source>Keep Trying</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keychain error" xml:space="preserve">
<source>Keychain error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Large file!" xml:space="preserve">
<source>Large file!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No active profile" xml:space="preserve">
<source>No active profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No network connection" xml:space="preserve">
<source>No network connection</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to downgrade the database." xml:space="preserve">
<source>Open the app to downgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to upgrade the database." xml:space="preserve">
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unsupported format" xml:space="preserve">
<source>Unsupported format</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong database passphrase" xml:space="preserve">
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
InfoPlist.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/
@@ -699,8 +699,8 @@
<source>All new messages from %@ will be hidden!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All users" xml:space="preserve">
<source>All users</source>
<trans-unit id="All profiles" xml:space="preserve">
<source>All profiles</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="All your contacts will remain connected." xml:space="preserve">
@@ -1036,6 +1036,10 @@
<source>Blocked by admin</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Blur media" xml:space="preserve">
<source>Blur media</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Both you and your contact can add message reactions." xml:space="preserve">
<source>Both you and your contact can add message reactions.</source>
<target>自分も相手もメッセージへのリアクションを追加できます。</target>
@@ -1465,6 +1469,10 @@ This is your own one-time link!</source>
<target>接続エラー (AUTH)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection notifications" xml:space="preserve">
<source>Connection notifications</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Connection request sent!" xml:space="preserve">
<source>Connection request sent!</source>
<target>接続リクエストを送信しました!</target>
@@ -1652,8 +1660,8 @@ This is your own one-time link!</source>
<target>現在の暗証フレーズ…</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Current user" xml:space="preserve">
<source>Current user</source>
<trans-unit id="Current profile" xml:space="preserve">
<source>Current profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
@@ -2028,10 +2036,18 @@ This cannot be undone!</source>
<source>Desktop devices</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server address of %@ is incompatible with forwarding server %@ settings." xml:space="preserve">
<source>Destination server address of %@ is incompatible with forwarding server %@ settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Destination server error: %@" xml:space="preserve">
<source>Destination server error: %@</source>
<note>snd error text</note>
</trans-unit>
<trans-unit id="Destination server version of %@ is incompatible with forwarding server %@." xml:space="preserve">
<source>Destination server version of %@ is incompatible with forwarding server %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Detailed statistics" xml:space="preserve">
<source>Detailed statistics</source>
<note>No comment provided by engineer.</note>
@@ -2095,6 +2111,10 @@ This cannot be undone!</source>
<target>すべて無効</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disabled" xml:space="preserve">
<source>Disabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Disappearing message" xml:space="preserve">
<source>Disappearing message</source>
<target>消えるメッセージ</target>
@@ -2306,6 +2326,10 @@ This cannot be undone!</source>
<target>自己破壊パスコードを有効にする</target>
<note>set passcode view</note>
</trans-unit>
<trans-unit id="Enabled" xml:space="preserve">
<source>Enabled</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Enabled for" xml:space="preserve">
<source>Enabled for</source>
<note>No comment provided by engineer.</note>
@@ -2468,6 +2492,10 @@ This cannot be undone!</source>
<target>設定変更にエラー発生</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error connecting to forwarding server %@. Please try later." xml:space="preserve">
<source>Error connecting to forwarding server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error creating address" xml:space="preserve">
<source>Error creating address</source>
<target>アドレス作成にエラー発生</target>
@@ -2943,6 +2971,18 @@ This cannot be undone!</source>
<source>Forwarded from</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server %@ failed to connect to destination server %@. Please try later." xml:space="preserve">
<source>Forwarding server %@ failed to connect to destination server %@. Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server address is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server address is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server version is incompatible with network settings: %@." xml:space="preserve">
<source>Forwarding server version is incompatible with network settings: %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Forwarding server: %@&#10;Destination server error: %@" xml:space="preserve">
<source>Forwarding server: %1$@
Destination server error: %2$@</source>
@@ -3723,6 +3763,10 @@ This is your link for group %@!</source>
<target>最大 30 秒で即時受信します。</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Medium" xml:space="preserve">
<source>Medium</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Member" xml:space="preserve">
<source>Member</source>
<target>メンバー</target>
@@ -4139,7 +4183,7 @@ This is your link for group %@!</source>
<trans-unit id="Off" xml:space="preserve">
<source>Off</source>
<target>オフ</target>
<note>No comment provided by engineer.</note>
<note>blur media</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
@@ -4474,10 +4518,6 @@ Error: %@</source>
<target>パスフレーズを失くさないように保管してください。失くすと変更できなくなります。</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please try later." xml:space="preserve">
<source>Please try later.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Polish interface" xml:space="preserve">
<source>Polish interface</source>
<target>ポーランド語UI</target>
@@ -5577,6 +5617,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Show message status</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show percentage" xml:space="preserve">
<source>Show percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Show preview" xml:space="preserve">
<source>Show preview</source>
<target>プレビューを表示</target>
@@ -5687,6 +5731,10 @@ Enable in *Network &amp; servers* settings.</source>
<target>小グループ(最大20名)</target>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Soft" xml:space="preserve">
<source>Soft</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Some non-fatal errors occurred during import - you may see Chat console for more details." xml:space="preserve">
<source>Some non-fatal errors occurred during import - you may see Chat console for more details.</source>
<target>インポート中に致命的でないエラーが発生しました - 詳細はチャットコンソールを参照してください。</target>
@@ -5781,6 +5829,10 @@ Enable in *Network &amp; servers* settings.</source>
<source>Stopping chat</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Strong" xml:space="preserve">
<source>Strong</source>
<note>blur media</note>
</trans-unit>
<trans-unit id="Submit" xml:space="preserve">
<source>Submit</source>
<target>送信</target>
@@ -5794,10 +5846,6 @@ Enable in *Network &amp; servers* settings.</source>
<source>Subscription errors</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscription percentage" xml:space="preserve">
<source>Subscription percentage</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Subscriptions ignored" xml:space="preserve">
<source>Subscriptions ignored</source>
<note>No comment provided by engineer.</note>
@@ -6796,8 +6844,8 @@ Repeat join request?</source>
<source>You can make it visible to your SimpleX contacts via Settings.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="You can now send messages to %@" xml:space="preserve">
<source>You can now send messages to %@</source>
<trans-unit id="You can now chat with %@" xml:space="preserve">
<source>You can now chat with %@</source>
<target>%@ にメッセージを送信できるようになりました</target>
<note>notification body</note>
</trans-unit>
@@ -7190,7 +7238,7 @@ SimpleX サーバーはあなたのプロファイルを参照できません。
</trans-unit>
<trans-unit id="blocked by admin" xml:space="preserve">
<source>blocked by admin</source>
<note>blocked chat item</note>
<note>marked deleted chat item preview text</note>
</trans-unit>
<trans-unit id="bold" xml:space="preserve">
<source>bold</source>
@@ -8049,4 +8097,154 @@ last received msg: %2$@</source>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/InfoPlist.strings" source-language="en" target-language="ja" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="CFBundleDisplayName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle display name</note>
</trans-unit>
<trans-unit id="CFBundleName" xml:space="preserve">
<source>SimpleX SE</source>
<note>Bundle name</note>
</trans-unit>
<trans-unit id="NSHumanReadableCopyright" xml:space="preserve">
<source>Copyright © 2024 SimpleX Chat. All rights reserved.</source>
<note>Copyright (human-readable)</note>
</trans-unit>
</body>
</file>
<file original="SimpleX SE/en.lproj/Localizable.strings" source-language="en" target-language="ja" datatype="plaintext">
<header>
<tool tool-id="com.apple.dt.xcode" tool-name="Xcode" tool-version="15.4" build-num="15F31d"/>
</header>
<body>
<trans-unit id="%@" xml:space="preserve">
<source>%@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Cannot access keychain to save database password" xml:space="preserve">
<source>Cannot access keychain to save database password</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Comment" xml:space="preserve">
<source>Comment</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Currently maximum supported file size is %@." xml:space="preserve">
<source>Currently maximum supported file size is %@.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database downgrade required" xml:space="preserve">
<source>Database downgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database error" xml:space="preserve">
<source>Database error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database passphrase is different from saved in the keychain." xml:space="preserve">
<source>Database passphrase is different from saved in the keychain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Database upgrade required" xml:space="preserve">
<source>Database upgrade required</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Dismiss Sheet" xml:space="preserve">
<source>Dismiss Sheet</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Encrypted database" xml:space="preserve">
<source>Encrypted database</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing file" xml:space="preserve">
<source>Error preparing file</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error preparing message" xml:space="preserve">
<source>Error preparing message</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Error: %@" xml:space="preserve">
<source>Error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="File error" xml:space="preserve">
<source>File error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Incompatible database version" xml:space="preserve">
<source>Incompatible database version</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Invalid migration confirmation" xml:space="preserve">
<source>Invalid migration confirmation</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keep Trying" xml:space="preserve">
<source>Keep Trying</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Keychain error" xml:space="preserve">
<source>Keychain error</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Large file!" xml:space="preserve">
<source>Large file!</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No active profile" xml:space="preserve">
<source>No active profile</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="No network connection" xml:space="preserve">
<source>No network connection</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Ok" xml:space="preserve">
<source>Ok</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to downgrade the database." xml:space="preserve">
<source>Open the app to downgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Open the app to upgrade the database." xml:space="preserve">
<source>Open the app to upgrade the database.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Please create a profile in the SimpleX app" xml:space="preserve">
<source>Please create a profile in the SimpleX app</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sending File" xml:space="preserve">
<source>Sending File</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Share" xml:space="preserve">
<source>Share</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Sharing is not supported when passphrase is not stored in KeyChain." xml:space="preserve">
<source>Sharing is not supported when passphrase is not stored in KeyChain.</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unknown database error: %@" xml:space="preserve">
<source>Unknown database error: %@</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Unsupported format" xml:space="preserve">
<source>Unsupported format</source>
<note>No comment provided by engineer.</note>
</trans-unit>
<trans-unit id="Wrong database passphrase" xml:space="preserve">
<source>Wrong database passphrase</source>
<note>No comment provided by engineer.</note>
</trans-unit>
</body>
</file>
</xliff>
@@ -1,6 +1,9 @@
/* Bundle display name */
"CFBundleDisplayName" = "SimpleX NSE";
/* Bundle name */
"CFBundleName" = "SimpleX NSE";
/* Copyright (human-readable) */
"NSHumanReadableCopyright" = "Copyright © 2022 SimpleX Chat. All rights reserved.";
@@ -0,0 +1,7 @@
/*
Localizable.strings
SimpleX
Created by EP on 30/07/2024.
Copyright © 2024 SimpleX Chat. All rights reserved.
*/

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